Compare commits
75 Commits
codex/cove
...
pysssss/ap
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9005a5a27f | ||
|
|
49eab72789 | ||
|
|
429c1b952c | ||
|
|
2ff8a3afbc | ||
|
|
3057caf9a5 | ||
|
|
e92ac06920 | ||
|
|
f17331cd35 | ||
|
|
02283a11ea | ||
|
|
801e2bfdfa | ||
|
|
3d518ccb7c | ||
|
|
df54ef9894 | ||
|
|
7526bc2b12 | ||
|
|
72139d21a8 | ||
|
|
ebfbb58f2d | ||
|
|
36e1fb5c29 | ||
|
|
fe644a9fb5 | ||
|
|
2c638d67c3 | ||
|
|
bb39a51d46 | ||
|
|
a6db1ab3d6 | ||
|
|
76abe8eb3f | ||
|
|
9bcfda88f6 | ||
|
|
e7cdfc8c35 | ||
|
|
e97746fd16 | ||
|
|
549200a76c | ||
|
|
1993bf4290 | ||
|
|
927f3f8541 | ||
|
|
979a832845 | ||
|
|
1c1c257f92 | ||
|
|
96c2ae1182 | ||
|
|
2312b213ce | ||
|
|
ce8b107322 | ||
|
|
831813a9db | ||
|
|
6455a49f58 | ||
|
|
64d10da9d7 | ||
|
|
3f84d4f5f2 | ||
|
|
b846cf4171 | ||
|
|
5383e23d24 | ||
|
|
9b8dd27f3d | ||
|
|
a818b7eee8 | ||
|
|
87d0a110cd | ||
|
|
b65da23915 | ||
|
|
07356e3253 | ||
|
|
51182127f3 | ||
|
|
fdfa9882b1 | ||
|
|
e970f5457b | ||
|
|
06d5443de1 | ||
|
|
86219d117d | ||
|
|
88cd848245 | ||
|
|
5dbb560ef0 | ||
|
|
f973626ebc | ||
|
|
e9729ca272 | ||
|
|
c51f963ef2 | ||
|
|
5e23f76642 | ||
|
|
f642384674 | ||
|
|
f80deb9655 | ||
|
|
75af0430fc | ||
|
|
cc74e1dc65 | ||
|
|
989773995a | ||
|
|
8ee6fc6f5f | ||
|
|
d9fd2e8c2f | ||
|
|
414469ed3c | ||
|
|
8e0622e423 | ||
|
|
be251d540a | ||
|
|
6bb1dc972f | ||
|
|
9065b845fc | ||
|
|
61ebcb514d | ||
|
|
b5fd5fd54c | ||
|
|
70c2e5e70e | ||
|
|
8bd12134b2 | ||
|
|
160d7c7a63 | ||
|
|
51efcf0424 | ||
|
|
0975a7ffbc | ||
|
|
8bebdb3021 | ||
|
|
b8207f2647 | ||
|
|
787815eb09 |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
@@ -1,3 +1,3 @@
|
||||
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 20 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path id="Vector" d="M20 32V0C20 5.39616 15.5172 9.78053 10 9.78053C4.48276 9.78053 0 5.416 0 0V32C0 26.6038 4.48276 22.2195 10 22.2195C15.5172 22.2195 20 26.6038 20 32Z" fill="var(--fill-0, #F2FF59)"/>
|
||||
<svg width="20" height="32" viewBox="0 0 20 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20 32V0C20 5.39616 15.5172 9.78053 10 9.78053C4.48276 9.78053 0 5.416 0 0V32C0 26.6038 4.48276 22.2195 10 22.2195C15.5172 22.2195 20 26.6038 20 32Z" fill="#F2FF59"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 380 B After Width: | Height: | Size: 279 B |
@@ -4,6 +4,7 @@ import { config as dotenvConfig } from 'dotenv'
|
||||
import MCR from 'monocart-coverage-reports'
|
||||
|
||||
import { COVERAGE_OUTPUT_DIR } from '@e2e/coverageConfig'
|
||||
import { TOURS } from '@/platform/onboarding/onboardingTours'
|
||||
import { NodeBadgeMode } from '@/types/nodeSource'
|
||||
import { ComfyActionbar } from '@e2e/fixtures/components/Actionbar'
|
||||
import { ComfyTemplates } from '@e2e/fixtures/components/Templates'
|
||||
@@ -28,6 +29,7 @@ import {
|
||||
ModelLibrarySidebarTab,
|
||||
NodeLibrarySidebarTab,
|
||||
NodeLibrarySidebarTabV2,
|
||||
SidebarTab,
|
||||
WorkflowsSidebarTab
|
||||
} from '@e2e/fixtures/components/SidebarTab'
|
||||
import { Topbar } from '@e2e/fixtures/components/Topbar'
|
||||
@@ -70,6 +72,7 @@ class ComfyPropertiesPanel {
|
||||
}
|
||||
|
||||
class ComfyMenu {
|
||||
private _appsTab: SidebarTab | null = null
|
||||
private _assetsTab: AssetsSidebarTab | null = null
|
||||
private _modelLibraryTab: ModelLibrarySidebarTab | null = null
|
||||
private _nodeLibraryTab: NodeLibrarySidebarTab | null = null
|
||||
@@ -104,6 +107,11 @@ class ComfyMenu {
|
||||
return this._nodeLibraryTabV2
|
||||
}
|
||||
|
||||
get appsTab() {
|
||||
this._appsTab ??= new SidebarTab(this.page, 'apps')
|
||||
return this._appsTab
|
||||
}
|
||||
|
||||
get assetsTab() {
|
||||
this._assetsTab ??= new AssetsSidebarTab(this.page)
|
||||
return this._assetsTab
|
||||
@@ -535,6 +543,8 @@ export const comfyPageFixture = base.extend<{
|
||||
'Comfy.userId': userId,
|
||||
// Set tutorial completed to true to avoid loading the tutorial workflow.
|
||||
'Comfy.TutorialCompleted': true,
|
||||
// An auto-opened tour's blocker would break unrelated tests.
|
||||
'Comfy.OnboardingCoachmarks.Seen': Object.keys(TOURS),
|
||||
'Comfy.Queue.MaxHistoryItems': 64,
|
||||
'Comfy.SnapToGrid.GridSize': testComfySnapToGridGridSize,
|
||||
'Comfy.VueNodes.AutoScaleLayout': false,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { expect } from '@playwright/test'
|
||||
import type { WorkspaceStore } from '@e2e/types/globals'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
class SidebarTab {
|
||||
export class SidebarTab {
|
||||
public readonly tabButton: Locator
|
||||
public readonly selectedTabButton: Locator
|
||||
|
||||
|
||||
74
browser_tests/fixtures/components/Tour.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
export type CoachTour = 'appMode'
|
||||
|
||||
const SEEN_SETTING = 'Comfy.OnboardingCoachmarks.Seen'
|
||||
|
||||
/** Accessible name of each tour's in-app replay (help) button. */
|
||||
const TOUR_REPLAY_BUTTONS: Record<CoachTour, string> = {
|
||||
appMode: 'Take a tour of App Mode'
|
||||
}
|
||||
|
||||
/** Coach-mark overlay (src/platform/onboarding/TourOverlay.vue). */
|
||||
export class OnboardingCoachmarks {
|
||||
public readonly landing: Locator
|
||||
public readonly landingStartButton: Locator
|
||||
public readonly landingSkipButton: Locator
|
||||
/** The current spotlight step card (the dialog carrying a "Step N of M" label). */
|
||||
public readonly card: Locator
|
||||
public readonly cardNextButton: Locator
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.landing = page.getByTestId('coach-landing')
|
||||
this.landingStartButton = this.landing.getByRole('button', {
|
||||
name: 'Start tutorial'
|
||||
})
|
||||
this.landingSkipButton = this.landing.getByRole('button', {
|
||||
name: 'Skip for now'
|
||||
})
|
||||
this.card = page.getByRole('dialog').filter({ hasText: /Step \d+ of \d+/ })
|
||||
this.cardNextButton = this.card.getByRole('button', { name: 'Next' })
|
||||
}
|
||||
|
||||
/** The tour's in-app help button, which replays it past the seen-flag. */
|
||||
replayButton(tour: CoachTour): Locator {
|
||||
return this.page.getByRole('button', { name: TOUR_REPLAY_BUTTONS[tour] })
|
||||
}
|
||||
|
||||
/** The spotlight card while it is showing the given step number. */
|
||||
cardForStep(step: number): Locator {
|
||||
return this.card.filter({ hasText: new RegExp(`Step ${step} of `) })
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the pre-seeded seen-flag (so dismissal assertions observe it being
|
||||
* set again) and clicks the tour's replay button, which must be mounted.
|
||||
*/
|
||||
async startTour(tour: CoachTour) {
|
||||
await this.clearSeen()
|
||||
await this.replayButton(tour).click()
|
||||
}
|
||||
|
||||
private async clearSeen() {
|
||||
await this.page.evaluate(
|
||||
async (key) => window.app!.extensionManager.setting.set(key, []),
|
||||
SEEN_SETTING
|
||||
)
|
||||
}
|
||||
|
||||
/** An element a tour points at, by its `data-coach-id` anchor. */
|
||||
coachAnchor(id: string): Locator {
|
||||
return this.page.locator(`[data-coach-id="${id}"]`)
|
||||
}
|
||||
|
||||
async seen(tour: CoachTour): Promise<boolean> {
|
||||
const seen = await this.page.evaluate(
|
||||
async (key) =>
|
||||
(await window.app!.extensionManager.setting.get(key)) as
|
||||
| string[]
|
||||
| undefined,
|
||||
SEEN_SETTING
|
||||
)
|
||||
return !!seen?.includes(tour)
|
||||
}
|
||||
}
|
||||
@@ -42,16 +42,14 @@ export class AppModeHelper {
|
||||
public readonly imagePickerPopover: Locator
|
||||
/** The Run button in the app mode footer. */
|
||||
public readonly runButton: Locator
|
||||
/** The welcome screen shown when app mode has no outputs or no nodes. */
|
||||
/** The welcome screen shown when app mode has nodes but no outputs. */
|
||||
public readonly welcome: Locator
|
||||
/** The empty workflow message shown when no nodes exist. */
|
||||
public readonly emptyWorkflowText: Locator
|
||||
/** The "Build app" button shown when nodes exist but no outputs. */
|
||||
public readonly buildAppButton: Locator
|
||||
/** The "Back to workflow" button on the welcome screen. */
|
||||
public readonly backToWorkflowButton: Locator
|
||||
/** The "Load template" button shown when no nodes exist. */
|
||||
public readonly loadTemplateButton: Locator
|
||||
/** The get started page shown when the graph is empty. */
|
||||
public readonly getStarted: Locator
|
||||
/** The "Discover all templates" button on the get started page. */
|
||||
public readonly getStartedDiscoverButton: Locator
|
||||
/** The cancel button for an in-progress run in the output history. */
|
||||
public readonly cancelRunButton: Locator
|
||||
/** Arrange-step placeholder shown when outputs are configured but no run has happened. */
|
||||
@@ -111,15 +109,10 @@ export class AppModeHelper {
|
||||
.getByTestId(TestIds.linear.runButton)
|
||||
.getByRole('button', { name: /run/i })
|
||||
this.welcome = this.page.getByTestId(TestIds.appMode.welcome)
|
||||
this.emptyWorkflowText = this.page.getByTestId(
|
||||
TestIds.appMode.emptyWorkflow
|
||||
)
|
||||
this.buildAppButton = this.page.getByTestId(TestIds.appMode.buildApp)
|
||||
this.backToWorkflowButton = this.page.getByTestId(
|
||||
TestIds.appMode.backToWorkflow
|
||||
)
|
||||
this.loadTemplateButton = this.page.getByTestId(
|
||||
TestIds.appMode.loadTemplate
|
||||
this.getStarted = this.page.getByTestId(TestIds.appMode.getStarted)
|
||||
this.getStartedDiscoverButton = this.page.getByTestId(
|
||||
TestIds.appMode.getStartedDiscover
|
||||
)
|
||||
this.cancelRunButton = this.page.getByTestId(
|
||||
TestIds.outputHistory.cancelRun
|
||||
|
||||
@@ -218,10 +218,9 @@ export const TestIds = {
|
||||
appMode: {
|
||||
widgetItem: 'app-mode-widget-item',
|
||||
welcome: 'linear-welcome',
|
||||
emptyWorkflow: 'linear-welcome-empty-workflow',
|
||||
buildApp: 'linear-welcome-build-app',
|
||||
backToWorkflow: 'linear-welcome-back-to-workflow',
|
||||
loadTemplate: 'linear-welcome-load-template',
|
||||
getStarted: 'linear-get-started',
|
||||
getStartedDiscover: 'linear-get-started-discover',
|
||||
arrangePreview: 'linear-arrange-preview',
|
||||
arrangeNoOutputs: 'linear-arrange-no-outputs',
|
||||
arrangeSwitchToOutputs: 'linear-arrange-switch-to-outputs',
|
||||
@@ -238,6 +237,9 @@ export const TestIds = {
|
||||
renameInput: 'subgraph-breadcrumb-rename-input',
|
||||
menu: (key: string) => `subgraph-breadcrumb-menu-${key}`
|
||||
},
|
||||
workflowActions: {
|
||||
viewModeToggle: 'view-mode-toggle'
|
||||
},
|
||||
templates: {
|
||||
content: 'template-workflows-content',
|
||||
workflowCard: (id: string) => `template-workflow-${id}`
|
||||
|
||||
11
browser_tests/fixtures/tourFixture.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { test as base } from '@playwright/test'
|
||||
|
||||
import { OnboardingCoachmarks } from '@e2e/fixtures/components/Tour'
|
||||
|
||||
export const onboardingFixture = base.extend<{
|
||||
onboarding: OnboardingCoachmarks
|
||||
}>({
|
||||
onboarding: async ({ page }, use) => {
|
||||
await use(new OnboardingCoachmarks(page))
|
||||
}
|
||||
})
|
||||
@@ -137,6 +137,125 @@ test.describe('App mode usage', () => {
|
||||
await expect.poll(() => fileComboWidget.getValue()).toBe(targetImage)
|
||||
})
|
||||
|
||||
test('Shares the graph side toolbar, filtered to assets + apps', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { sideToolbar, nodeLibraryTab, assetsTab, appsTab } = comfyPage.menu
|
||||
|
||||
await test.step('Graph mode shows the full toolbar', async () => {
|
||||
await expect(sideToolbar).toBeVisible()
|
||||
await expect(nodeLibraryTab.tabButton).toBeVisible()
|
||||
})
|
||||
|
||||
await test.step('App mode reuses it with only assets + apps', async () => {
|
||||
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
|
||||
await expect(comfyPage.appMode.centerPanel).toBeVisible()
|
||||
|
||||
await expect(sideToolbar).toBeVisible()
|
||||
await expect(assetsTab.tabButton).toBeVisible()
|
||||
await expect(appsTab.tabButton).toBeVisible()
|
||||
await expect(nodeLibraryTab.tabButton).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test('Workflow actions menu keeps the same position across graph/app mode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Toggling graph<->app mode happens from this control, so it must not move
|
||||
// out from under the cursor as the mode flips.
|
||||
const graphActions = comfyPage.page
|
||||
.getByTestId(TestIds.breadcrumb.subgraph)
|
||||
.getByRole('button', { name: 'Workflow actions' })
|
||||
await expect(graphActions).toBeVisible()
|
||||
const graphBox = await graphActions.boundingBox()
|
||||
|
||||
expect(graphBox).not.toBeNull()
|
||||
|
||||
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
|
||||
await expect(comfyPage.appMode.centerPanel).toBeVisible()
|
||||
|
||||
const appActions = comfyPage.page
|
||||
.getByTestId(TestIds.linear.centerPanel)
|
||||
.getByRole('button', { name: 'Workflow actions' })
|
||||
await expect(appActions).toBeVisible()
|
||||
|
||||
// The toggle segments reorder (morph) as the mode flips, so poll until the
|
||||
// active control settles at the same x it occupied in graph mode.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const box = await appActions.boundingBox()
|
||||
return box ? Math.abs(box.x - graphBox!.x) : Infinity
|
||||
})
|
||||
.toBeLessThanOrEqual(1)
|
||||
})
|
||||
|
||||
test('Toggle segment flips mode without opening the menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const toggle = comfyPage.page.getByTestId(
|
||||
TestIds.workflowActions.viewModeToggle
|
||||
)
|
||||
await expect(toggle).toBeVisible()
|
||||
|
||||
await comfyPage.page.getByRole('button', { name: 'Enter app mode' }).click()
|
||||
|
||||
await expect(comfyPage.appMode.centerPanel).toBeVisible()
|
||||
// The inactive segment switches mode; it must not also open the actions menu.
|
||||
await expect(comfyPage.page.getByRole('menu')).toBeHidden()
|
||||
await expect(toggle).toBeVisible()
|
||||
})
|
||||
|
||||
test('Toggle segment flips mode via keyboard without opening the menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const appSegment = comfyPage.page.getByRole('button', {
|
||||
name: 'Enter app mode'
|
||||
})
|
||||
await appSegment.focus()
|
||||
await appSegment.press('Enter')
|
||||
|
||||
await expect(comfyPage.appMode.centerPanel).toBeVisible()
|
||||
// Keyboard activation of the inactive segment must switch mode without the
|
||||
// keydown bubbling to the trigger and opening the actions menu.
|
||||
await expect(comfyPage.page.getByRole('menu')).toBeHidden()
|
||||
})
|
||||
|
||||
test('Mode toggle re-appears after exiting the builder to graph mode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const toggle = comfyPage.page.getByTestId(
|
||||
TestIds.workflowActions.viewModeToggle
|
||||
)
|
||||
await comfyPage.appMode.enableLinearMode()
|
||||
await expect(toggle).toBeVisible()
|
||||
|
||||
await comfyPage.appMode.enterBuilder()
|
||||
await expect(toggle).toBeHidden()
|
||||
await expect(comfyPage.appMode.centerPanel).toBeHidden()
|
||||
|
||||
await comfyPage.appMode.footer.exitButton.click()
|
||||
// Exiting the builder lands in graph mode: the app-mode-only center panel
|
||||
// stays hidden while the toggle's teleport host re-mounts and the toggle
|
||||
// re-appears.
|
||||
await expect(toggle).toBeVisible()
|
||||
await expect(comfyPage.appMode.centerPanel).toBeHidden()
|
||||
})
|
||||
|
||||
test('Mode toggle survives a sidebar tab remounting the app panel', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const toggle = comfyPage.page.getByTestId(
|
||||
TestIds.workflowActions.viewModeToggle
|
||||
)
|
||||
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
|
||||
await expect(comfyPage.appMode.centerPanel).toBeVisible()
|
||||
await expect(toggle).toBeVisible()
|
||||
|
||||
// Opening a sidebar tab remounts the app panel; the toggle re-renders with it.
|
||||
await comfyPage.menu.assetsTab.tabButton.click()
|
||||
await expect(toggle).toBeVisible()
|
||||
})
|
||||
|
||||
test.describe('Mobile', { tag: ['@mobile'] }, () => {
|
||||
test('panel navigation', async ({ comfyPage }) => {
|
||||
const { mobile } = comfyPage.appMode
|
||||
@@ -184,3 +303,45 @@ test.describe('App mode usage', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('App mode credits', () => {
|
||||
const API_PRICED_NODE = 'FluxProUltraImageNode'
|
||||
|
||||
test('shows the credit breakdown popover for priced nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.NodeBadge.ShowApiPricing', true)
|
||||
await comfyPage.page.evaluate((type) => {
|
||||
const registered = window.LiteGraph!.registered_node_types[type] as {
|
||||
nodeData?: { price_badge?: unknown }
|
||||
}
|
||||
if (!registered?.nodeData) throw new Error(`No nodeData for ${type}`)
|
||||
registered.nodeData.price_badge = {
|
||||
engine: 'jsonata',
|
||||
expr: "{'type': 'text', 'text': '99.9 credits/Run'}",
|
||||
depends_on: { widgets: [], inputs: [], input_groups: [] }
|
||||
}
|
||||
}, API_PRICED_NODE)
|
||||
|
||||
await comfyPage.nodeOps.addNode(API_PRICED_NODE)
|
||||
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
|
||||
await expect(comfyPage.appMode.centerPanel).toBeVisible()
|
||||
|
||||
// The run/subscribe button flags that the workflow needs credits, even when
|
||||
// the pill collapses to its icon (kept in the accessible name).
|
||||
const runButton = comfyPage.appMode.runButton
|
||||
await expect(runButton).toBeVisible()
|
||||
await expect(runButton).toHaveAccessibleName(/Uses credits/)
|
||||
|
||||
// Hovering the button reveals the per-node credit breakdown.
|
||||
await runButton.hover()
|
||||
const breakdown = comfyPage.page.getByRole('list', {
|
||||
name: 'Credit breakdown by model'
|
||||
})
|
||||
await expect(breakdown).toBeVisible()
|
||||
await expect(breakdown).toContainText('99.9 credits/Run')
|
||||
await expect(
|
||||
comfyPage.page.getByText('Requires additional credits')
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,14 +9,12 @@ test.describe('App mode welcome states', { tag: '@ui' }, () => {
|
||||
await comfyPage.appMode.suppressVueNodeSwitchPopup()
|
||||
})
|
||||
|
||||
test('Empty workflow text is visible when no nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test('Get started page is visible when no nodes', async ({ comfyPage }) => {
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
await comfyPage.appMode.toggleAppMode()
|
||||
|
||||
await expect(comfyPage.appMode.welcome).toBeVisible()
|
||||
await expect(comfyPage.appMode.emptyWorkflowText).toBeVisible()
|
||||
await expect(comfyPage.appMode.getStarted).toBeVisible()
|
||||
await expect(comfyPage.appMode.welcome).toBeHidden()
|
||||
await expect(comfyPage.appMode.buildAppButton).toBeHidden()
|
||||
})
|
||||
|
||||
@@ -27,35 +25,27 @@ test.describe('App mode welcome states', { tag: '@ui' }, () => {
|
||||
|
||||
await expect(comfyPage.appMode.welcome).toBeVisible()
|
||||
await expect(comfyPage.appMode.buildAppButton).toBeVisible()
|
||||
await expect(comfyPage.appMode.emptyWorkflowText).toBeHidden()
|
||||
await expect(comfyPage.appMode.getStarted).toBeHidden()
|
||||
})
|
||||
|
||||
test('Empty workflow and build app are hidden when app has outputs', async ({
|
||||
test('Get started and build app are hidden when app has outputs', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
|
||||
|
||||
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
|
||||
await expect(comfyPage.appMode.emptyWorkflowText).toBeHidden()
|
||||
await expect(comfyPage.appMode.getStarted).toBeHidden()
|
||||
await expect(comfyPage.appMode.buildAppButton).toBeHidden()
|
||||
})
|
||||
|
||||
test('Back to workflow returns to graph mode', async ({ comfyPage }) => {
|
||||
await comfyPage.appMode.toggleAppMode()
|
||||
|
||||
await expect(comfyPage.appMode.welcome).toBeVisible()
|
||||
await comfyPage.appMode.backToWorkflowButton.click()
|
||||
|
||||
await expect(comfyPage.canvas).toBeVisible()
|
||||
await expect(comfyPage.appMode.welcome).toBeHidden()
|
||||
})
|
||||
|
||||
test('Load template opens template selector', async ({ comfyPage }) => {
|
||||
test('Discover all templates opens template selector', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
await comfyPage.appMode.toggleAppMode()
|
||||
|
||||
await expect(comfyPage.appMode.welcome).toBeVisible()
|
||||
await comfyPage.appMode.loadTemplateButton.click()
|
||||
await expect(comfyPage.appMode.getStarted).toBeVisible()
|
||||
await comfyPage.appMode.getStartedDiscoverButton.click()
|
||||
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 90 KiB |
98
browser_tests/tests/tour.spec.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import { onboardingFixture } from '@e2e/fixtures/tourFixture'
|
||||
|
||||
import { COACH_IDS } from '@/platform/onboarding/onboardingTours'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, onboardingFixture)
|
||||
|
||||
// Relies on the default workflow the test server loads (locally: pnpm dev:test)
|
||||
// — an empty graph would show the welcome screen, not the tour's controls.
|
||||
test.describe('Onboarding coachmarks', { tag: '@ui' }, () => {
|
||||
test.describe('app-mode tour', () => {
|
||||
test('opens on the welcome landing, focuses Start, and Skip dismisses it', async ({
|
||||
comfyPage,
|
||||
onboarding
|
||||
}) => {
|
||||
await comfyPage.appMode.enterAppModeWithInputs([])
|
||||
await onboarding.startTour('appMode')
|
||||
const coach = onboarding
|
||||
|
||||
await expect(coach.landing).toBeVisible()
|
||||
await expect(coach.landing.getByRole('heading')).toHaveText(
|
||||
'Welcome to Apps'
|
||||
)
|
||||
await expect(coach.landingStartButton).toBeFocused()
|
||||
|
||||
await coach.landingSkipButton.click()
|
||||
await expect(coach.landing).toBeHidden()
|
||||
expect(await coach.seen('appMode')).toBe(true)
|
||||
})
|
||||
|
||||
test('Escape dismisses the welcome landing and marks it seen', async ({
|
||||
comfyPage,
|
||||
onboarding
|
||||
}) => {
|
||||
await comfyPage.appMode.enterAppModeWithInputs([])
|
||||
await onboarding.startTour('appMode')
|
||||
const coach = onboarding
|
||||
await expect(coach.landing).toBeVisible()
|
||||
await expect(coach.landingStartButton).toBeFocused()
|
||||
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await expect(coach.landing).toBeHidden()
|
||||
expect(await coach.seen('appMode')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('coach anchors', () => {
|
||||
test('every registry id resolves to an element (drift guard)', async ({
|
||||
comfyPage,
|
||||
onboarding
|
||||
}) => {
|
||||
const coach = onboarding
|
||||
await comfyPage.appMode.enterAppModeWithInputs([])
|
||||
// The assets panel only mounts once its button is clicked; every other
|
||||
// anchor should already be present in a running app.
|
||||
for (const id of Object.values(COACH_IDS).filter(
|
||||
(id) => id !== COACH_IDS.assetsPanel
|
||||
)) {
|
||||
await expect(coach.coachAnchor(id)).toBeVisible()
|
||||
}
|
||||
await coach.coachAnchor(COACH_IDS.assetsButton).click()
|
||||
await expect(coach.coachAnchor(COACH_IDS.assetsPanel)).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('spotlight placement', () => {
|
||||
test('every spotlight card stays fully within the viewport', async ({
|
||||
comfyPage,
|
||||
onboarding
|
||||
}) => {
|
||||
const coach = onboarding
|
||||
// Read settled placements, not a transient mid-animation frame.
|
||||
await comfyPage.page.emulateMedia({ reducedMotion: 'reduce' })
|
||||
await comfyPage.appMode.enterAppModeWithInputs([])
|
||||
|
||||
await coach.startTour('appMode')
|
||||
await expect(coach.landing).toBeVisible()
|
||||
await coach.landingStartButton.click()
|
||||
|
||||
// Step 3 (outputs) is the vertically-centred `leftCenter` placement that
|
||||
// must not slide off the top/bottom edge.
|
||||
for (const step of [1, 2, 3]) {
|
||||
const card = coach.cardForStep(step)
|
||||
await expect(card).toBeVisible()
|
||||
await expect(card).toBeInViewport({ ratio: 1 })
|
||||
await coach.cardNextButton.click()
|
||||
}
|
||||
|
||||
// Step 4 (assets button) advances by clicking its target, not Next.
|
||||
await expect(coach.cardForStep(4)).toBeInViewport({ ratio: 1 })
|
||||
await coach.coachAnchor('assets-button').click()
|
||||
|
||||
await expect(coach.cardForStep(5)).toBeInViewport({ ratio: 1 })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 52 KiB |
@@ -73,6 +73,7 @@
|
||||
"@comfyorg/shared-frontend-utils": "workspace:*",
|
||||
"@comfyorg/tailwind-utils": "workspace:*",
|
||||
"@customerio/cdp-analytics-browser": "catalog:",
|
||||
"@floating-ui/vue": "catalog:",
|
||||
"@formkit/auto-animate": "catalog:",
|
||||
"@iconify/json": "catalog:",
|
||||
"@primeuix/forms": "catalog:",
|
||||
|
||||
6
pnpm-lock.yaml
generated
@@ -30,6 +30,9 @@ catalogs:
|
||||
'@eslint/js':
|
||||
specifier: ^10.0.1
|
||||
version: 10.0.1
|
||||
'@floating-ui/vue':
|
||||
specifier: ^1.1.11
|
||||
version: 1.1.11
|
||||
'@formkit/auto-animate':
|
||||
specifier: ^0.9.0
|
||||
version: 0.9.0
|
||||
@@ -465,6 +468,9 @@ importers:
|
||||
'@customerio/cdp-analytics-browser':
|
||||
specifier: 'catalog:'
|
||||
version: 0.5.3
|
||||
'@floating-ui/vue':
|
||||
specifier: 'catalog:'
|
||||
version: 1.1.11(vue@3.5.34(typescript@5.9.3))
|
||||
'@formkit/auto-animate':
|
||||
specifier: 'catalog:'
|
||||
version: 0.9.0
|
||||
|
||||
@@ -18,6 +18,7 @@ catalog:
|
||||
'@comfyorg/comfyui-electron-types': 0.6.2
|
||||
'@customerio/cdp-analytics-browser': ^0.5.3
|
||||
'@eslint/js': ^10.0.1
|
||||
'@floating-ui/vue': ^1.1.11
|
||||
'@formkit/auto-animate': ^0.9.0
|
||||
'@iconify-json/lucide': ^1.1.178
|
||||
'@iconify/json': ^2.2.380
|
||||
|
||||
BIN
public/assets/images/app-mode-landing.png
Normal file
|
After Width: | Height: | Size: 355 KiB |
@@ -15,6 +15,7 @@ const IGNORE_PATTERNS = [
|
||||
/^dataTypes\./, // Data types might be referenced dynamically
|
||||
/^contextMenu\./, // Context menu items might be dynamic
|
||||
/^color\./, // Color names might be used dynamically
|
||||
/^onboardingCoachmarks\.[^.]+\.[^.]+\./, // Step keys derived as onboardingCoachmarks.<tour>.<step>.*
|
||||
// Auto-generated categories from collect-i18n-general.ts
|
||||
/^menuLabels\./, // Menu labels generated from command labels
|
||||
/^settingsCategories\./, // Settings categories generated from setting definitions
|
||||
|
||||
@@ -1,5 +1,89 @@
|
||||
@import '@comfyorg/design-system/css/style.css';
|
||||
|
||||
/* Generating screen ambient glow — a slowly rotating, blurred conic gradient.
|
||||
--gen-angle must be a registered <angle> so the conic gradient interpolates
|
||||
instead of jumping between keyframes. */
|
||||
@property --gen-angle {
|
||||
syntax: '<angle>';
|
||||
inherits: false;
|
||||
initial-value: 0deg;
|
||||
}
|
||||
|
||||
@keyframes gen-angle-spin {
|
||||
to {
|
||||
--gen-angle: 360deg;
|
||||
}
|
||||
}
|
||||
|
||||
.gen-glow {
|
||||
position: absolute;
|
||||
inset: -28%;
|
||||
border-radius: 50%;
|
||||
background: conic-gradient(
|
||||
from var(--gen-angle),
|
||||
#3b82f63b,
|
||||
#8b5cf633,
|
||||
#d946ef2b,
|
||||
#ec489933,
|
||||
#f9731629,
|
||||
#14b8a62e,
|
||||
#3b82f63b
|
||||
);
|
||||
filter: blur(60px);
|
||||
opacity: 0.34;
|
||||
animation: gen-angle-spin 12s linear infinite;
|
||||
mask-image: radial-gradient(circle, #000 0%, #000 22%, rgb(0 0 0 / 0) 70%);
|
||||
}
|
||||
|
||||
.gen-glow::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 8%;
|
||||
border-radius: 50%;
|
||||
background: conic-gradient(
|
||||
from calc(var(--gen-angle) + 120deg),
|
||||
#3b82f629,
|
||||
#8b5cf621,
|
||||
#d946ef1c,
|
||||
#ec489924,
|
||||
#f973161a,
|
||||
#14b8a621,
|
||||
#3b82f629
|
||||
);
|
||||
filter: blur(34px);
|
||||
opacity: 0.39;
|
||||
mask-image: radial-gradient(
|
||||
circle,
|
||||
#000 0%,
|
||||
rgb(0 0 0 / 0.62) 36%,
|
||||
rgb(0 0 0 / 0.22) 50%,
|
||||
rgb(0 0 0 / 0) 64%
|
||||
);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.gen-glow {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.genfan-enter-active,
|
||||
.genfan-leave-active,
|
||||
.gen-card {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Generating fan cards fade in/out so adding and evicting cards stays smooth. */
|
||||
.genfan-enter-active,
|
||||
.genfan-leave-active {
|
||||
transition: opacity 0.42s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.genfan-enter-from,
|
||||
.genfan-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Use 0.001ms instead of 0s so transitionend/animationend events still fire
|
||||
and JS listeners aren't broken. */
|
||||
.disable-animations *,
|
||||
|
||||
@@ -28,6 +28,8 @@ const formatNumber = ({
|
||||
return new Intl.NumberFormat(locale, merged).format(value)
|
||||
}
|
||||
|
||||
export const CREDITS_ICON = 'icon-[lucide--coins]'
|
||||
|
||||
export const CREDITS_PER_USD = 211
|
||||
export const COMFY_CREDIT_RATE_CENTS = CREDITS_PER_USD / 100 // credits per cent
|
||||
|
||||
|
||||
94
src/components/appMode/AppModeToolbar.test.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import AppModeToolbar from './AppModeToolbar.vue'
|
||||
|
||||
const appModeState = vi.hoisted(() => ({ enableAppBuilder: true }))
|
||||
const enterBuilder = vi.hoisted(() => vi.fn())
|
||||
const nodes = vi.hoisted(() => ({ set: (_value: boolean) => {} }))
|
||||
|
||||
vi.mock('@/composables/useAppMode', () => ({
|
||||
useAppMode: () => ({ enableAppBuilder: appModeState.enableAppBuilder })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/appModeStore', async () => {
|
||||
const { ref } = await import('vue')
|
||||
const hasNodes = ref(true)
|
||||
nodes.set = (value: boolean) => {
|
||||
hasNodes.value = value
|
||||
}
|
||||
return { useAppModeStore: () => ({ enterBuilder, hasNodes }) }
|
||||
})
|
||||
|
||||
const BUILD_AN_APP = 'Build an app'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
linearMode: { appModeToolbar: { buildAnApp: BUILD_AN_APP } }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function setHasNodes(hasNodes: boolean) {
|
||||
nodes.set(hasNodes)
|
||||
}
|
||||
|
||||
function renderToolbar() {
|
||||
const user = userEvent.setup()
|
||||
const result = render(AppModeToolbar, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
WorkflowActionsDropdown: true,
|
||||
Button: {
|
||||
inheritAttrs: false,
|
||||
template:
|
||||
'<button v-bind="$attrs" @click="$emit(\'click\', $event)"><slot /></button>'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
return { ...result, user }
|
||||
}
|
||||
|
||||
describe('AppModeToolbar', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
appModeState.enableAppBuilder = true
|
||||
setHasNodes(true)
|
||||
})
|
||||
|
||||
it('shows an enabled build button and enters the builder on click', async () => {
|
||||
setHasNodes(true)
|
||||
const { user } = renderToolbar()
|
||||
|
||||
const button = screen.getByRole('button', { name: BUILD_AN_APP })
|
||||
expect(button).toBeEnabled()
|
||||
|
||||
await user.click(button)
|
||||
|
||||
expect(enterBuilder).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('disables the build button when there are no nodes', () => {
|
||||
setHasNodes(false)
|
||||
renderToolbar()
|
||||
|
||||
expect(screen.getByRole('button', { name: BUILD_AN_APP })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('hides the build button when app building is disabled', () => {
|
||||
setHasNodes(true)
|
||||
appModeState.enableAppBuilder = false
|
||||
renderToolbar()
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: BUILD_AN_APP })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,119 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import WorkflowActionsDropdown from '@/components/common/WorkflowActionsDropdown.vue'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import {
|
||||
openShareDialog,
|
||||
prefetchShareDialog
|
||||
} from '@/platform/workflow/sharing/composables/lazyShareDialog'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
const { t } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const { enableAppBuilder } = useAppMode()
|
||||
const appModeStore = useAppModeStore()
|
||||
const { enterBuilder } = appModeStore
|
||||
const { toastErrorHandler } = useErrorHandling()
|
||||
const { flags } = useFeatureFlags()
|
||||
const { hasNodes } = storeToRefs(appModeStore)
|
||||
const tooltipOptions = { showDelay: 300, hideDelay: 300 }
|
||||
|
||||
const isAssetsActive = computed(
|
||||
() => workspaceStore.sidebarTab.activeSidebarTab?.id === 'assets'
|
||||
)
|
||||
const isAppsActive = computed(
|
||||
() => workspaceStore.sidebarTab.activeSidebarTab?.id === 'apps'
|
||||
)
|
||||
|
||||
function openAssets() {
|
||||
void commandStore.execute('Workspace.ToggleSidebarTab.assets')
|
||||
}
|
||||
|
||||
function showApps() {
|
||||
void commandStore.execute('Workspace.ToggleSidebarTab.apps')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pointer-events-auto flex flex-row items-start gap-2">
|
||||
<div class="pointer-events-auto flex flex-col gap-2">
|
||||
<Button
|
||||
v-if="enableAppBuilder"
|
||||
v-tooltip.right="{
|
||||
value: t('linearMode.appModeToolbar.appBuilder'),
|
||||
...tooltipOptions
|
||||
}"
|
||||
variant="secondary"
|
||||
size="unset"
|
||||
:disabled="!hasNodes"
|
||||
:aria-label="t('linearMode.appModeToolbar.appBuilder')"
|
||||
class="size-10 rounded-lg"
|
||||
@click="enterBuilder"
|
||||
>
|
||||
<i class="icon-[lucide--hammer] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="isCloud && flags.workflowSharingEnabled"
|
||||
v-tooltip.right="{
|
||||
value: t('actionbar.shareTooltip'),
|
||||
...tooltipOptions
|
||||
}"
|
||||
variant="secondary"
|
||||
size="unset"
|
||||
:aria-label="t('actionbar.shareTooltip')"
|
||||
class="size-10 rounded-lg"
|
||||
@click="() => openShareDialog().catch(toastErrorHandler)"
|
||||
@pointerenter="prefetchShareDialog"
|
||||
>
|
||||
<i class="icon-[lucide--send] size-4" />
|
||||
</Button>
|
||||
|
||||
<div
|
||||
class="flex w-10 flex-col overflow-hidden rounded-lg bg-secondary-background"
|
||||
>
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
value: t('sideToolbar.mediaAssets.title'),
|
||||
...tooltipOptions
|
||||
}"
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
:aria-label="t('sideToolbar.mediaAssets.title')"
|
||||
:class="
|
||||
cn('size-10', isAssetsActive && 'bg-secondary-background-hover')
|
||||
"
|
||||
@click="openAssets"
|
||||
>
|
||||
<i class="icon-[comfy--image-ai-edit] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
value: t('linearMode.appModeToolbar.apps'),
|
||||
...tooltipOptions
|
||||
}"
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
:aria-label="t('linearMode.appModeToolbar.apps')"
|
||||
:class="
|
||||
cn('size-10', isAppsActive && 'bg-secondary-background-hover')
|
||||
"
|
||||
@click="showApps"
|
||||
>
|
||||
<i class="icon-[lucide--panels-top-left] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<WorkflowActionsDropdown source="app_mode_toolbar" />
|
||||
<Button
|
||||
v-if="enableAppBuilder"
|
||||
variant="base"
|
||||
size="unset"
|
||||
:disabled="!hasNodes"
|
||||
:aria-label="t('linearMode.appModeToolbar.buildAnApp')"
|
||||
class="h-10 gap-1.5 rounded-lg px-3 font-normal"
|
||||
@click="enterBuilder"
|
||||
>
|
||||
<i class="icon-[lucide--hammer] size-4" />
|
||||
<span>{{ t('linearMode.appModeToolbar.buildAnApp') }}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
71
src/components/breadcrumb/SubgraphBreadcrumb.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import SubgraphBreadcrumb from './SubgraphBreadcrumb.vue'
|
||||
|
||||
const canvasState = vi.hoisted(() => ({ linearMode: false }))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => ({ activeWorkflow: { filename: 'workflow.json' } })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/subgraphNavigationStore', () => ({
|
||||
useSubgraphNavigationStore: () => ({ navigationStack: [] })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/subgraphStore', () => ({
|
||||
useSubgraphStore: () => ({ isSubgraphBlueprint: () => false })
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({ linearMode: canvasState.linearMode })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/element/useOverflowObserver', () => ({
|
||||
useOverflowObserver: () => ({
|
||||
dispose: vi.fn(),
|
||||
checkOverflow: vi.fn(),
|
||||
disposed: { value: false }
|
||||
})
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: { g: { graphNavigation: 'Graph navigation' } }
|
||||
}
|
||||
})
|
||||
|
||||
function renderBreadcrumb() {
|
||||
return render(SubgraphBreadcrumb, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
directives: { tooltip: {} },
|
||||
stubs: {
|
||||
WorkflowActionsDropdown: { template: '<div data-testid="wad" />' },
|
||||
Breadcrumb: true,
|
||||
Button: true,
|
||||
SubgraphBreadcrumbItem: true
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('SubgraphBreadcrumb', () => {
|
||||
beforeEach(() => {
|
||||
canvasState.linearMode = false
|
||||
})
|
||||
|
||||
it('renders the workflow actions dropdown when not in linear mode', () => {
|
||||
renderBreadcrumb()
|
||||
expect(screen.getByTestId('wad')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the workflow actions dropdown in linear mode', () => {
|
||||
canvasState.linearMode = true
|
||||
renderBreadcrumb()
|
||||
expect(screen.queryByTestId('wad')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -14,7 +14,10 @@
|
||||
'--p-breadcrumb-icon-width': `${ICON_WIDTH}px`
|
||||
}"
|
||||
>
|
||||
<WorkflowActionsDropdown source="breadcrumb_subgraph_menu_selected" />
|
||||
<WorkflowActionsDropdown
|
||||
v-if="!canvasStore.linearMode"
|
||||
source="breadcrumb_subgraph_menu_selected"
|
||||
/>
|
||||
<Button
|
||||
v-if="isInSubgraph"
|
||||
class="back-button pointer-events-auto ml-1.5 size-8 shrink-0 border border-transparent bg-transparent p-0 transition-all hover:rounded-lg hover:border-interface-stroke hover:bg-comfy-menu-bg"
|
||||
@@ -71,6 +74,7 @@ const ICON_WIDTH = 20
|
||||
|
||||
const workflowStore = useWorkflowStore()
|
||||
const navigationStore = useSubgraphNavigationStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const breadcrumbRef = ref<InstanceType<typeof Breadcrumb>>()
|
||||
const workflowName = computed(() => workflowStore.activeWorkflow?.filename)
|
||||
const isBlueprint = computed(() =>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
class="p-1 text-amber-400"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--component]" />
|
||||
<i :class="CREDITS_ICON" />
|
||||
</template>
|
||||
</Tag>
|
||||
<div :class="textClass">
|
||||
@@ -29,7 +29,10 @@ import Tag from 'primevue/tag'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
|
||||
import {
|
||||
CREDITS_ICON,
|
||||
formatCreditsFromCents
|
||||
} from '@/base/credits/comfyCredits'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
const { textClass, showCreditsOnly } = defineProps<{
|
||||
|
||||
167
src/components/common/WorkflowActionsDropdown.test.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import WorkflowActionsDropdown from './WorkflowActionsDropdown.vue'
|
||||
|
||||
const spies = vi.hoisted(() => ({
|
||||
execute: vi.fn(),
|
||||
trackUiButtonClicked: vi.fn(),
|
||||
markAsSeen: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({ displayLinearMode: false })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/commandStore', () => ({
|
||||
useCommandStore: () => ({ execute: spies.execute, commands: [] })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/keybindings/keybindingStore', () => ({
|
||||
useKeybindingStore: () => ({
|
||||
getKeybindingByCommandId: () => ({ combo: { toString: () => 'Ctrl+L' } })
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({ trackUiButtonClicked: spies.trackUiButtonClicked })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useWorkflowActionsMenu', async () => {
|
||||
const { ref } = await import('vue')
|
||||
return { useWorkflowActionsMenu: () => ({ menuItems: ref([]) }) }
|
||||
})
|
||||
|
||||
vi.mock('@/composables/useNewMenuItemIndicator', async () => {
|
||||
const { ref } = await import('vue')
|
||||
return {
|
||||
useNewMenuItemIndicator: () => ({
|
||||
hasUnseenItems: ref(true),
|
||||
markAsSeen: spies.markAsSeen
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: { shortcutSuffix: ' ({shortcut})' },
|
||||
breadcrumbsMenu: {
|
||||
graph: 'Graph',
|
||||
app: 'App',
|
||||
enterNodeGraph: 'Enter node graph',
|
||||
enterAppMode: 'Enter app mode',
|
||||
workflowActions: 'Workflow actions'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function renderDropdown() {
|
||||
const user = userEvent.setup()
|
||||
const result = render(WorkflowActionsDropdown, {
|
||||
props: { source: 'test' },
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
directives: { tooltip: {} },
|
||||
stubs: {
|
||||
// Emits update:open on mount so handleOpen's telemetry path is exercised.
|
||||
DropdownMenuRoot: {
|
||||
emits: ['update:open'],
|
||||
mounted() {
|
||||
this.$emit('update:open', true)
|
||||
},
|
||||
template: '<div><slot /></div>'
|
||||
},
|
||||
DropdownMenuTrigger: { template: '<div><slot /></div>' },
|
||||
DropdownMenuPortal: { template: '<div><slot /></div>' },
|
||||
DropdownMenuContent: { template: '<div><slot /></div>' },
|
||||
WorkflowActionsList: true,
|
||||
Button: {
|
||||
inheritAttrs: false,
|
||||
template:
|
||||
'<button v-bind="$attrs" @click="$emit(\'click\', $event)"><slot /></button>'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
return { ...result, user }
|
||||
}
|
||||
|
||||
describe('WorkflowActionsDropdown', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('keeps the active segment label in its accessible name alongside the actions label', () => {
|
||||
renderDropdown()
|
||||
|
||||
// Graph is the active segment, so its name must contain the visible "Graph"
|
||||
// label (label-in-name) while still matching the "Workflow actions" trigger.
|
||||
const active = screen.getByRole('button', { name: /Workflow actions/ })
|
||||
expect(active).toHaveAttribute('aria-label', 'Graph Workflow actions')
|
||||
})
|
||||
|
||||
it('labels the inactive segment with its switch action only', () => {
|
||||
renderDropdown()
|
||||
|
||||
const inactive = screen.getByRole('button', { name: 'Enter app mode' })
|
||||
expect(inactive).toHaveAttribute('aria-label', 'Enter app mode')
|
||||
})
|
||||
|
||||
it('toggles the view mode when the inactive segment is clicked', async () => {
|
||||
const { user } = renderDropdown()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Enter app mode' }))
|
||||
|
||||
expect(spies.execute).toHaveBeenCalledWith('Comfy.ToggleLinear', {
|
||||
metadata: { source: 'test' }
|
||||
})
|
||||
})
|
||||
|
||||
it('does not toggle the view mode when the active segment is clicked', async () => {
|
||||
const { user } = renderDropdown()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Workflow actions/ }))
|
||||
|
||||
expect(spies.execute).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('switches mode when the inactive segment is activated by keyboard', async () => {
|
||||
const { user } = renderDropdown()
|
||||
const inactive = screen.getByRole('button', { name: 'Enter app mode' })
|
||||
|
||||
inactive.focus()
|
||||
await user.keyboard('{Enter}')
|
||||
|
||||
// The keydown guard stops the event bubbling to the trigger, but native
|
||||
// button activation still switches mode.
|
||||
expect(spies.execute).toHaveBeenCalledWith('Comfy.ToggleLinear', {
|
||||
metadata: { source: 'test' }
|
||||
})
|
||||
})
|
||||
|
||||
it('does not switch mode when the active segment is activated by keyboard', async () => {
|
||||
const { user } = renderDropdown()
|
||||
const active = screen.getByRole('button', { name: /Workflow actions/ })
|
||||
|
||||
active.focus()
|
||||
await user.keyboard('{Enter}')
|
||||
|
||||
expect(spies.execute).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('marks new items as seen and reports telemetry when the menu opens', () => {
|
||||
renderDropdown()
|
||||
|
||||
expect(spies.markAsSeen).toHaveBeenCalled()
|
||||
expect(spies.trackUiButtonClicked).toHaveBeenCalledWith({
|
||||
button_id: 'test',
|
||||
element_group: 'workflow_actions'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,11 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRoot,
|
||||
DropdownMenuTrigger
|
||||
} from 'reka-ui'
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import WorkflowActionsList from '@/components/common/WorkflowActionsList.vue'
|
||||
@@ -17,25 +18,67 @@ import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
|
||||
type ViewMode = 'graph' | 'app'
|
||||
|
||||
interface ViewModeSegment {
|
||||
mode: ViewMode
|
||||
icon: string
|
||||
label: string
|
||||
switchLabel: string
|
||||
switchTooltip: string
|
||||
active: boolean
|
||||
}
|
||||
|
||||
const { source, align = 'start' } = defineProps<{
|
||||
source: string
|
||||
align?: 'start' | 'center' | 'end'
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const canvasStore = useCanvasStore()
|
||||
const keybindingStore = useKeybindingStore()
|
||||
const dropdownOpen = ref(false)
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
const { menuItems } = useWorkflowActionsMenu(
|
||||
() => useCommandStore().execute('Comfy.RenameWorkflow'),
|
||||
{ isRoot: true }
|
||||
)
|
||||
|
||||
const { hasUnseenItems, markAsSeen } = useNewMenuItemIndicator(
|
||||
() => menuItems.value
|
||||
)
|
||||
|
||||
const toggleShortcut = computed(() => {
|
||||
const shortcut = keybindingStore
|
||||
.getKeybindingByCommandId('Comfy.ToggleLinear')
|
||||
?.combo.toString()
|
||||
return shortcut ? t('g.shortcutSuffix', { shortcut }) : ''
|
||||
})
|
||||
|
||||
const segments = computed<ViewModeSegment[]>(() => [
|
||||
{
|
||||
mode: 'graph',
|
||||
icon: 'icon-[comfy--workflow]',
|
||||
label: t('breadcrumbsMenu.graph'),
|
||||
switchLabel: t('breadcrumbsMenu.enterNodeGraph'),
|
||||
switchTooltip: t('breadcrumbsMenu.enterNodeGraph') + toggleShortcut.value,
|
||||
active: !canvasStore.displayLinearMode
|
||||
},
|
||||
{
|
||||
mode: 'app',
|
||||
icon: 'icon-[lucide--panels-top-left]',
|
||||
label: t('breadcrumbsMenu.app'),
|
||||
switchLabel: t('breadcrumbsMenu.enterAppMode'),
|
||||
switchTooltip: t('breadcrumbsMenu.enterAppMode') + toggleShortcut.value,
|
||||
active: canvasStore.displayLinearMode
|
||||
}
|
||||
])
|
||||
|
||||
// Inactive segment first (left), active last (right). On mode switch the array
|
||||
// reorders and TransitionGroup FLIP-animates the keyed nodes to their new spots.
|
||||
const orderedSegments = computed(() =>
|
||||
[...segments.value].sort((a, b) => Number(a.active) - Number(b.active))
|
||||
)
|
||||
|
||||
function handleOpen(open: boolean) {
|
||||
if (open) {
|
||||
markAsSeen()
|
||||
@@ -46,23 +89,32 @@ function handleOpen(open: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
function toggleModeTooltip() {
|
||||
const label = canvasStore.linearMode
|
||||
? t('breadcrumbsMenu.enterNodeGraph')
|
||||
: t('breadcrumbsMenu.enterAppMode')
|
||||
const shortcut = keybindingStore
|
||||
.getKeybindingByCommandId('Comfy.ToggleLinear')
|
||||
?.combo.toString()
|
||||
return label + (shortcut ? t('g.shortcutSuffix', { shortcut }) : '')
|
||||
}
|
||||
|
||||
function toggleLinearMode() {
|
||||
function switchMode() {
|
||||
dropdownOpen.value = false
|
||||
void useCommandStore().execute('Comfy.ToggleLinear', {
|
||||
metadata: { source }
|
||||
})
|
||||
}
|
||||
|
||||
// The container is the dropdown trigger, so an inactive segment must stop its
|
||||
// pointer event from bubbling up and opening the menu instead of switching.
|
||||
function onSegmentPointerDown(seg: ViewModeSegment, e: PointerEvent) {
|
||||
if (!seg.active) e.stopPropagation()
|
||||
}
|
||||
|
||||
// Keyboard mirror of the pointer guard: stop Enter/Space on an inactive segment
|
||||
// from bubbling to the trigger. The button's native activation still fires
|
||||
// onSegmentClick to switch mode, so the menu stays closed.
|
||||
function onSegmentKeydown(seg: ViewModeSegment, e: KeyboardEvent) {
|
||||
if (!seg.active && (e.key === 'Enter' || e.key === ' ')) e.stopPropagation()
|
||||
}
|
||||
|
||||
function onSegmentClick(seg: ViewModeSegment, e: MouseEvent) {
|
||||
if (seg.active) return
|
||||
e.stopPropagation()
|
||||
switchMode()
|
||||
}
|
||||
|
||||
const tooltipPt = {
|
||||
root: {
|
||||
style: {
|
||||
@@ -75,7 +127,7 @@ const tooltipPt = {
|
||||
style: { whiteSpace: 'nowrap' }
|
||||
},
|
||||
arrow: {
|
||||
class: '!left-[16px]'
|
||||
style: { left: '16px' }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -86,69 +138,81 @@ const tooltipPt = {
|
||||
:modal="false"
|
||||
@update:open="handleOpen"
|
||||
>
|
||||
<slot name="button" :has-unseen-items="hasUnseenItems">
|
||||
<DropdownMenuTrigger as-child>
|
||||
<div
|
||||
class="pointer-events-auto inline-flex items-center rounded-lg bg-secondary-background"
|
||||
data-testid="view-mode-toggle"
|
||||
class="group pointer-events-auto relative inline-block rounded-lg bg-base-background p-1"
|
||||
>
|
||||
<Button
|
||||
v-tooltip.bottom="{
|
||||
value: toggleModeTooltip(),
|
||||
showDelay: 300,
|
||||
hideDelay: 300,
|
||||
pt: tooltipPt
|
||||
}"
|
||||
:aria-label="
|
||||
canvasStore.linearMode
|
||||
? t('breadcrumbsMenu.enterNodeGraph')
|
||||
: t('breadcrumbsMenu.enterAppMode')
|
||||
"
|
||||
variant="base"
|
||||
class="m-1"
|
||||
@pointerdown.stop
|
||||
@click="toggleLinearMode"
|
||||
<TransitionGroup
|
||||
tag="div"
|
||||
move-class="transition-[background-color,color,transform] duration-200"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
<i
|
||||
class="size-4"
|
||||
:class="
|
||||
canvasStore.linearMode
|
||||
? 'icon-[lucide--panels-top-left]'
|
||||
: 'icon-[comfy--workflow]'
|
||||
"
|
||||
/>
|
||||
</Button>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button
|
||||
v-tooltip="{
|
||||
value: t('breadcrumbsMenu.workflowActions'),
|
||||
v-for="seg in orderedSegments"
|
||||
:key="seg.mode"
|
||||
v-tooltip.bottom="{
|
||||
value: seg.active
|
||||
? t('breadcrumbsMenu.workflowActions')
|
||||
: seg.switchTooltip,
|
||||
showDelay: 300,
|
||||
hideDelay: 300
|
||||
hideDelay: 300,
|
||||
pt: seg.active ? undefined : tooltipPt
|
||||
}"
|
||||
variant="secondary"
|
||||
type="button"
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
:aria-label="t('breadcrumbsMenu.workflowActions')"
|
||||
class="relative h-10 gap-1 rounded-lg pr-2 pl-2.5 text-center data-[state=open]:bg-secondary-background-hover data-[state=open]:shadow-interface"
|
||||
:aria-label="
|
||||
seg.active
|
||||
? `${seg.label} ${t('breadcrumbsMenu.workflowActions')}`
|
||||
: seg.switchLabel
|
||||
"
|
||||
:class="
|
||||
cn(
|
||||
'relative flex h-8 items-center gap-0 rounded-md font-normal transition-[background-color,color,transform] duration-200',
|
||||
seg.active
|
||||
? 'bg-secondary-background pr-2 pl-2.5 text-base-foreground group-data-[state=open]:bg-secondary-background-hover group-data-[state=open]:shadow-interface hover:bg-secondary-background'
|
||||
: 'w-8 justify-center bg-transparent text-muted-foreground hover:bg-secondary-background hover:text-base-foreground'
|
||||
)
|
||||
"
|
||||
@pointerdown="onSegmentPointerDown(seg, $event)"
|
||||
@keydown="onSegmentKeydown(seg, $event)"
|
||||
@click="onSegmentClick(seg, $event)"
|
||||
>
|
||||
<span>{{
|
||||
canvasStore.linearMode
|
||||
? t('breadcrumbsMenu.app')
|
||||
: t('breadcrumbsMenu.graph')
|
||||
}}</span>
|
||||
<i
|
||||
class="icon-[lucide--chevron-down] size-4 text-muted-foreground"
|
||||
/>
|
||||
<i :class="cn('size-4 shrink-0', seg.icon)" aria-hidden="true" />
|
||||
<span
|
||||
v-if="hasUnseenItems"
|
||||
:class="
|
||||
cn(
|
||||
'grid transition-[grid-template-columns,opacity] duration-200',
|
||||
seg.active
|
||||
? 'ml-1.5 grid-cols-[1fr] opacity-100'
|
||||
: 'grid-cols-[0fr] opacity-0'
|
||||
)
|
||||
"
|
||||
>
|
||||
<span
|
||||
class="flex min-w-0 items-center overflow-hidden text-sm leading-none whitespace-nowrap"
|
||||
>
|
||||
{{ seg.label }}
|
||||
<i
|
||||
class="ml-1 icon-[lucide--chevron-down] size-4 shrink-0 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="seg.active && hasUnseenItems"
|
||||
aria-hidden="true"
|
||||
class="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-primary-background"
|
||||
/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</slot>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent
|
||||
:align
|
||||
:side-offset="5"
|
||||
:side-offset="8"
|
||||
:collision-padding="10"
|
||||
class="z-1000 min-w-56 rounded-lg border border-border-subtle bg-base-background px-2 py-3 shadow-interface"
|
||||
>
|
||||
|
||||
@@ -471,26 +471,14 @@ const workflowTemplatesStore = useWorkflowTemplatesStore()
|
||||
const {
|
||||
loadTemplates,
|
||||
loadWorkflowTemplate,
|
||||
getTemplateThumbnailUrl,
|
||||
getTemplateTitle,
|
||||
getTemplateDescription
|
||||
getTemplateDescription,
|
||||
getEffectiveSourceModule,
|
||||
isAppTemplate,
|
||||
getBaseThumbnailSrc,
|
||||
getOverlayThumbnailSrc
|
||||
} = useTemplateWorkflows()
|
||||
|
||||
const getEffectiveSourceModule = (template: TemplateInfo) =>
|
||||
template.sourceModule || 'default'
|
||||
|
||||
const isAppTemplate = (template: TemplateInfo) => template.name.endsWith('.app')
|
||||
|
||||
const getBaseThumbnailSrc = (template: TemplateInfo) => {
|
||||
const sm = getEffectiveSourceModule(template)
|
||||
return getTemplateThumbnailUrl(template, sm, sm === 'default' ? '1' : '')
|
||||
}
|
||||
|
||||
const getOverlayThumbnailSrc = (template: TemplateInfo) => {
|
||||
const sm = getEffectiveSourceModule(template)
|
||||
return getTemplateThumbnailUrl(template, sm, sm === 'default' ? '2' : '')
|
||||
}
|
||||
|
||||
// Open tutorial in new tab
|
||||
const openTutorial = (template: TemplateInfo) => {
|
||||
if (template.tutorialUrl) {
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
@max-reached="showCeilingWarning = true"
|
||||
>
|
||||
<template #prefix>
|
||||
<i class="icon-[lucide--component] size-4 shrink-0 text-gold-500" />
|
||||
<i :class="cn(CREDITS_ICON, 'size-4 shrink-0 text-gold-500')" />
|
||||
</template>
|
||||
</FormattedNumberStepper>
|
||||
</div>
|
||||
@@ -98,7 +98,7 @@
|
||||
v-if="isBelowMin"
|
||||
class="m-0 flex items-center justify-center gap-1 px-8 pt-4 text-center text-sm text-red-500"
|
||||
>
|
||||
<i class="icon-[lucide--component] size-4" />
|
||||
<i :class="cn(CREDITS_ICON, 'size-4')" />
|
||||
{{
|
||||
$t('credits.topUp.minRequired', {
|
||||
credits: formatNumber(usdToCredits(MIN_AMOUNT))
|
||||
@@ -109,7 +109,7 @@
|
||||
v-if="showCeilingWarning"
|
||||
class="m-0 flex items-center justify-center gap-1 px-8 pt-4 text-center text-sm text-gold-500"
|
||||
>
|
||||
<i class="icon-[lucide--component] size-4" />
|
||||
<i :class="cn(CREDITS_ICON, 'size-4')" />
|
||||
{{
|
||||
$t('credits.topUp.maxAllowed', {
|
||||
credits: formatNumber(usdToCredits(MAX_AMOUNT))
|
||||
@@ -154,7 +154,11 @@ import { useToast } from 'primevue/usetoast'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { creditsToUsd, usdToCredits } from '@/base/credits/comfyCredits'
|
||||
import {
|
||||
CREDITS_ICON,
|
||||
creditsToUsd,
|
||||
usdToCredits
|
||||
} from '@/base/credits/comfyCredits'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import FormattedNumberStepper from '@/components/ui/stepper/FormattedNumberStepper.vue'
|
||||
import { useAuthActions } from '@/composables/auth/useAuthActions'
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { ZIndex } from '@primeuix/utils/zindex'
|
||||
import type { Directive } from 'vue'
|
||||
|
||||
/** Shared PrimeVue/Reka modal stacking sequence; later registrations cover earlier ones. */
|
||||
export const MODAL_Z_KEY = 'modal'
|
||||
export const MODAL_Z_BASE = 1700
|
||||
|
||||
// Both Reka and PrimeVue dialogs can appear at any depth in dialogStack, in
|
||||
// any order. PrimeVue auto-increments a per-key z-index counter so later
|
||||
// dialogs always cover earlier ones; Reka uses a static z-1700 class which
|
||||
@@ -9,7 +13,7 @@ import type { Directive } from 'vue'
|
||||
// renderers share one stacking sequence: whichever dialog opens last wins.
|
||||
export const vRekaZIndex: Directive<HTMLElement> = {
|
||||
mounted(el) {
|
||||
ZIndex.set('modal', el, 1700)
|
||||
ZIndex.set(MODAL_Z_KEY, el, MODAL_Z_BASE)
|
||||
},
|
||||
beforeUnmount(el) {
|
||||
ZIndex.clear(el)
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="showUI && !isBuilderMode" #side-toolbar>
|
||||
<SideToolbar />
|
||||
<template #side-toolbar>
|
||||
<SideToolbar v-if="showUI && !isBuilderMode && !linearMode" />
|
||||
</template>
|
||||
<template v-if="showUI" #side-bar-panel>
|
||||
<div
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
)
|
||||
"
|
||||
>
|
||||
<i class="icon-[lucide--component] h-full bg-amber-400" />
|
||||
<i :class="cn(CREDITS_ICON, 'h-full bg-amber-400')" />
|
||||
<span class="truncate" v-text="text" />
|
||||
</span>
|
||||
<span
|
||||
@@ -21,6 +21,8 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import { CREDITS_ICON } from '@/base/credits/comfyCredits'
|
||||
|
||||
defineProps<{
|
||||
text: string
|
||||
rest?: string
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--component] size-3 text-amber-400"
|
||||
:class="cn(CREDITS_ICON, 'size-3 text-amber-400')"
|
||||
/>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
@@ -134,6 +134,8 @@ import { getProviderIcon, getProviderName } from '@/utils/categoryUtil'
|
||||
import { formatNumberWithSuffix, highlightQuery } from '@/utils/formatUtil'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import { CREDITS_ICON } from '@/base/credits/comfyCredits'
|
||||
|
||||
const {
|
||||
nodeDef,
|
||||
currentQuery,
|
||||
|
||||
205
src/components/sidebar/SideToolbar.test.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import Tooltip from 'primevue/tooltip'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import SideToolbar from './SideToolbar.vue'
|
||||
|
||||
interface TestTab {
|
||||
id: string
|
||||
icon: string
|
||||
tooltip: string
|
||||
label: string
|
||||
title: string
|
||||
}
|
||||
|
||||
const spies = vi.hoisted(() => ({
|
||||
trackUiButtonClicked: vi.fn(),
|
||||
toggleAssets: vi.fn()
|
||||
}))
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
linearMode: false,
|
||||
isMultiUserServer: false,
|
||||
sidebarTabs: [] as TestTab[],
|
||||
activeSidebarTab: null as { id: string } | null
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: false,
|
||||
isDesktop: false,
|
||||
isNightly: false
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workspaceStore', () => ({
|
||||
useWorkspaceStore: () => ({
|
||||
getSidebarTabs: () => state.sidebarTabs,
|
||||
sidebarTab: { activeSidebarTab: state.activeSidebarTab }
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: (key: string) => {
|
||||
if (key === 'Comfy.Sidebar.Size') return 'large'
|
||||
if (key === 'Comfy.Sidebar.Location') return 'left'
|
||||
return 'floating'
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/userStore', () => ({
|
||||
useUserStore: () => ({ isMultiUserServer: state.isMultiUserServer })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/commandStore', () => ({
|
||||
useCommandStore: () => ({
|
||||
commands: [
|
||||
{ id: 'Workspace.ToggleSidebarTab.assets', function: spies.toggleAssets }
|
||||
]
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({ linearMode: state.linearMode, canvas: null })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/keybindings/keybindingStore', () => ({
|
||||
useKeybindingStore: () => ({ getKeybindingByCommandId: () => undefined })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({ trackUiButtonClicked: spies.trackUiButtonClicked })
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: {} }
|
||||
})
|
||||
|
||||
type SideToolbarProps = ComponentProps<typeof SideToolbar>
|
||||
|
||||
function renderToolbar(props: SideToolbarProps = {}) {
|
||||
return render(SideToolbar, {
|
||||
props,
|
||||
global: {
|
||||
plugins: [PrimeVue, i18n],
|
||||
directives: { tooltip: Tooltip },
|
||||
stubs: {
|
||||
ComfyMenuButton: { template: '<div />' },
|
||||
SidebarTemplatesButton: { template: '<div />' },
|
||||
SidebarLogoutIcon: { template: '<div data-testid="logout" />' },
|
||||
SidebarHelpCenterIcon: { template: '<div />' },
|
||||
SidebarSettingsButton: { template: '<div />' },
|
||||
HelpCenterPopups: { template: '<div />' },
|
||||
SidebarBottomPanelToggleButton: {
|
||||
template: '<div data-testid="bottom-panel-toggle" />'
|
||||
},
|
||||
SidebarShortcutsToggleButton: {
|
||||
template: '<div data-testid="shortcuts-toggle" />'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const assetsTab: TestTab = {
|
||||
id: 'assets',
|
||||
icon: 'pi pi-image',
|
||||
tooltip: 'Assets',
|
||||
label: 'Assets',
|
||||
title: 'Assets'
|
||||
}
|
||||
|
||||
const workflowsTab: TestTab = {
|
||||
id: 'workflows',
|
||||
icon: 'pi pi-folder',
|
||||
tooltip: 'Workflows',
|
||||
label: 'Workflows',
|
||||
title: 'Workflows'
|
||||
}
|
||||
|
||||
describe('SideToolbar', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
state.linearMode = false
|
||||
state.isMultiUserServer = false
|
||||
state.sidebarTabs = [assetsTab, workflowsTab]
|
||||
state.activeSidebarTab = null
|
||||
})
|
||||
|
||||
it('renders only the tabs listed in visibleTabIds', () => {
|
||||
renderToolbar({ visibleTabIds: ['assets'] })
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Assets' })).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Workflows' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders all sidebar tabs when visibleTabIds is omitted', () => {
|
||||
renderToolbar()
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Assets' })).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Workflows' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('marks the toolbar as connected when forceConnected is true', () => {
|
||||
renderToolbar({ forceConnected: true })
|
||||
|
||||
expect(screen.getByTestId('side-toolbar')).toHaveClass('connected-sidebar')
|
||||
})
|
||||
|
||||
it('does not mark the toolbar as connected by default', () => {
|
||||
renderToolbar()
|
||||
|
||||
expect(screen.getByTestId('side-toolbar')).not.toHaveClass(
|
||||
'connected-sidebar'
|
||||
)
|
||||
})
|
||||
|
||||
it('shows the shortcuts and bottom panel toggles when not in linear mode', () => {
|
||||
state.linearMode = false
|
||||
renderToolbar()
|
||||
|
||||
expect(screen.getByTestId('shortcuts-toggle')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('bottom-panel-toggle')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the shortcuts and bottom panel toggles in linear mode', () => {
|
||||
state.linearMode = true
|
||||
renderToolbar()
|
||||
|
||||
expect(screen.queryByTestId('shortcuts-toggle')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('bottom-panel-toggle')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reports telemetry and runs the toggle command when a tab is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderToolbar({ visibleTabIds: ['assets'] })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Assets' }))
|
||||
|
||||
expect(spies.trackUiButtonClicked).toHaveBeenCalledWith({
|
||||
button_id: 'sidebar_tab_assets_media_selected',
|
||||
element_group: 'sidebar'
|
||||
})
|
||||
expect(spies.toggleAssets).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders the logout icon only on a multi-user server', () => {
|
||||
const { unmount } = renderToolbar()
|
||||
expect(screen.queryByTestId('logout')).not.toBeInTheDocument()
|
||||
unmount()
|
||||
|
||||
state.isMultiUserServer = true
|
||||
renderToolbar()
|
||||
expect(screen.getByTestId('logout')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -23,6 +23,7 @@
|
||||
<SidebarIcon
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
v-coachmark="tab.id === 'assets' ? 'assets-button' : undefined"
|
||||
:icon="tab.icon"
|
||||
:icon-badge="tab.iconBadge"
|
||||
:tooltip="tab.tooltip"
|
||||
@@ -42,8 +43,14 @@
|
||||
:is-small="isSmall"
|
||||
/>
|
||||
<SidebarHelpCenterIcon :is-small="isSmall" />
|
||||
<SidebarBottomPanelToggleButton v-if="!isCloud" :is-small="isSmall" />
|
||||
<SidebarShortcutsToggleButton :is-small="isSmall" />
|
||||
<SidebarBottomPanelToggleButton
|
||||
v-if="!isCloud && !canvasStore.linearMode"
|
||||
:is-small="isSmall"
|
||||
/>
|
||||
<SidebarShortcutsToggleButton
|
||||
v-if="!canvasStore.linearMode"
|
||||
:is-small="isSmall"
|
||||
/>
|
||||
<SidebarSettingsButton :is-small="isSmall" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -73,6 +80,7 @@ import ComfyMenuButton from '@/components/sidebar/ComfyMenuButton.vue'
|
||||
import SidebarBottomPanelToggleButton from '@/components/sidebar/SidebarBottomPanelToggleButton.vue'
|
||||
import SidebarSettingsButton from '@/components/sidebar/SidebarSettingsButton.vue'
|
||||
import SidebarShortcutsToggleButton from '@/components/sidebar/SidebarShortcutsToggleButton.vue'
|
||||
import { vCoachmark } from '@/platform/onboarding/vCoachmark'
|
||||
import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
@@ -89,6 +97,11 @@ import SidebarIcon from './SidebarIcon.vue'
|
||||
import SidebarLogoutIcon from './SidebarLogoutIcon.vue'
|
||||
import SidebarTemplatesButton from './SidebarTemplatesButton.vue'
|
||||
|
||||
const { visibleTabIds, forceConnected = false } = defineProps<{
|
||||
visibleTabIds?: string[]
|
||||
forceConnected?: boolean
|
||||
}>()
|
||||
|
||||
const NightlySurveyController =
|
||||
isNightly && !isCloud && !isDesktop
|
||||
? defineAsyncComponent(
|
||||
@@ -115,12 +128,18 @@ const sidebarLocation = computed<'left' | 'right'>(() =>
|
||||
const sidebarStyle = computed(() => settingStore.get('Comfy.Sidebar.Style'))
|
||||
const isConnected = computed(
|
||||
() =>
|
||||
forceConnected ||
|
||||
selectedTab.value ||
|
||||
isOverflowing.value ||
|
||||
sidebarStyle.value === 'connected'
|
||||
)
|
||||
|
||||
const tabs = computed(() => workspaceStore.getSidebarTabs())
|
||||
const tabs = computed(() => {
|
||||
const all = workspaceStore.getSidebarTabs()
|
||||
return visibleTabIds
|
||||
? all.filter((tab) => visibleTabIds.includes(tab.id))
|
||||
: all
|
||||
})
|
||||
const selectedTab = computed(() => workspaceStore.sidebarTab.activeSidebarTab)
|
||||
|
||||
/**
|
||||
|
||||
109
src/components/sidebar/SidebarHelpCenterIcon.test.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import SidebarHelpCenterIcon from './SidebarHelpCenterIcon.vue'
|
||||
|
||||
const typeformState = vi.hoisted(() => ({
|
||||
typeformError: false,
|
||||
isValidTypeformId: true,
|
||||
typeformId: 'jmmzmlKw'
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/surveys/useTypeformEmbed', async () => {
|
||||
const { computed } = await import('vue')
|
||||
return {
|
||||
useTypeformEmbed: () => ({
|
||||
typeformError: computed(() => typeformState.typeformError),
|
||||
isValidTypeformId: computed(() => typeformState.isValidTypeformId),
|
||||
typeformId: computed(() => typeformState.typeformId)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/composables/useHelpCenter', async () => {
|
||||
const { ref } = await import('vue')
|
||||
return {
|
||||
useHelpCenter: () => ({
|
||||
shouldShowRedDot: ref(false),
|
||||
toggleHelpCenter: vi.fn()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({ get: () => 'left' })
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', async () => {
|
||||
const { computed } = await import('vue')
|
||||
return {
|
||||
useCanvasStore: () => ({ linearMode: computed(() => true) })
|
||||
}
|
||||
})
|
||||
|
||||
const FEEDBACK_LOAD_ERROR =
|
||||
'Failed to load feedback form. Please try again later.'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
menu: { help: 'Help' },
|
||||
sideToolbar: { helpCenter: 'Help Center' },
|
||||
linearMode: {
|
||||
giveFeedback: 'Give feedback',
|
||||
feedbackLoadError: FEEDBACK_LOAD_ERROR
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function renderIcon() {
|
||||
return render(SidebarHelpCenterIcon, {
|
||||
props: { isSmall: false },
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
Popover: {
|
||||
template: '<div><slot name="button" /><slot /></div>'
|
||||
},
|
||||
SidebarIcon: true
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('SidebarHelpCenterIcon', () => {
|
||||
beforeEach(() => {
|
||||
typeformState.typeformError = false
|
||||
typeformState.isValidTypeformId = true
|
||||
})
|
||||
|
||||
it('mounts the Typeform embed container when the id is valid and loads', () => {
|
||||
const { container } = renderIcon()
|
||||
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- attribute hook: the embed target has no ARIA role
|
||||
expect(container.querySelector('[data-tf-widget]')).not.toBeNull()
|
||||
expect(screen.queryByText(FEEDBACK_LOAD_ERROR)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the localized fallback instead of the embed when loading fails', () => {
|
||||
typeformState.typeformError = true
|
||||
const { container } = renderIcon()
|
||||
|
||||
expect(screen.getByText(FEEDBACK_LOAD_ERROR)).toBeInTheDocument()
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- attribute hook: the embed target has no ARIA role
|
||||
expect(container.querySelector('[data-tf-widget]')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the localized fallback when the form id is invalid', () => {
|
||||
typeformState.isValidTypeformId = false
|
||||
const { container } = renderIcon()
|
||||
|
||||
expect(screen.getByText(FEEDBACK_LOAD_ERROR)).toBeInTheDocument()
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- attribute hook: the embed target has no ARIA role
|
||||
expect(container.querySelector('[data-tf-widget]')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,34 @@
|
||||
<template>
|
||||
<Popover
|
||||
v-if="linearMode"
|
||||
:side="sidebarOnLeft ? 'right' : 'left'"
|
||||
:side-offset="8"
|
||||
>
|
||||
<template #button>
|
||||
<SidebarIcon
|
||||
icon="pi pi-question-circle"
|
||||
class="comfy-help-center-btn"
|
||||
data-testid="help-center-button"
|
||||
:label="$t('menu.help')"
|
||||
:tooltip="$t('linearMode.giveFeedback')"
|
||||
:is-small="isSmall"
|
||||
/>
|
||||
</template>
|
||||
<div
|
||||
v-if="typeformError || !isValidTypeformId"
|
||||
class="text-danger p-4 text-sm"
|
||||
>
|
||||
{{ $t('linearMode.feedbackLoadError') }}
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
ref="feedbackRef"
|
||||
data-tf-auto-resize
|
||||
:data-tf-widget="typeformId"
|
||||
/>
|
||||
</Popover>
|
||||
<SidebarIcon
|
||||
v-else
|
||||
icon="pi pi-question-circle"
|
||||
class="comfy-help-center-btn"
|
||||
data-testid="help-center-button"
|
||||
@@ -13,13 +42,34 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, useTemplateRef } from 'vue'
|
||||
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import { useHelpCenter } from '@/composables/useHelpCenter'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTypeformEmbed } from '@/platform/surveys/useTypeformEmbed'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
|
||||
import SidebarIcon from './SidebarIcon.vue'
|
||||
|
||||
const APP_MODE_FEEDBACK_TYPEFORM_ID = 'jmmzmlKw'
|
||||
|
||||
defineProps<{
|
||||
isSmall: boolean
|
||||
}>()
|
||||
|
||||
const { shouldShowRedDot, toggleHelpCenter } = useHelpCenter()
|
||||
const { linearMode } = storeToRefs(useCanvasStore())
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const sidebarOnLeft = computed(
|
||||
() => settingStore.get('Comfy.Sidebar.Location') === 'left'
|
||||
)
|
||||
|
||||
const feedbackRef = useTemplateRef<HTMLDivElement>('feedbackRef')
|
||||
const { typeformError, isValidTypeformId, typeformId } = useTypeformEmbed(
|
||||
feedbackRef,
|
||||
APP_MODE_FEEDBACK_TYPEFORM_ID
|
||||
)
|
||||
</script>
|
||||
|
||||
90
src/components/sidebar/tabs/AppsSidebarTab.test.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import AppsSidebarTab from './AppsSidebarTab.vue'
|
||||
|
||||
const execute = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/stores/commandStore', () => ({
|
||||
useCommandStore: () => ({ execute })
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: { beta: 'Beta' },
|
||||
linearMode: {
|
||||
appModeToolbar: {
|
||||
apps: 'Apps',
|
||||
create: 'Create',
|
||||
createApp: 'Create app',
|
||||
appsEmptyMessage: 'No apps yet',
|
||||
appsEmptyMessageAction: 'Create one to get started'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function renderTab({ hasResults = true }: { hasResults?: boolean } = {}) {
|
||||
const user = userEvent.setup()
|
||||
const result = render(AppsSidebarTab, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
BaseWorkflowsSidebarTab: {
|
||||
template: `<div><slot name="header-actions" :has-results="${hasResults}" /><slot name="empty-state" /></div>`
|
||||
},
|
||||
Button: {
|
||||
inheritAttrs: false,
|
||||
template:
|
||||
'<button v-bind="$attrs" @click="$emit(\'click\', $event)"><slot /></button>'
|
||||
},
|
||||
NoResultsPlaceholder: {
|
||||
props: ['buttonLabel'],
|
||||
emits: ['action'],
|
||||
template:
|
||||
'<button @click="$emit(\'action\')">{{ buttonLabel }}</button>'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
return { ...result, user }
|
||||
}
|
||||
|
||||
describe('AppsSidebarTab', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('shows the create action only when there are results', () => {
|
||||
const { unmount } = renderTab({ hasResults: false })
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Create' })
|
||||
).not.toBeInTheDocument()
|
||||
unmount()
|
||||
|
||||
renderTab({ hasResults: true })
|
||||
expect(screen.getByRole('button', { name: 'Create' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('runs the new-workflow command when the create action is clicked', async () => {
|
||||
const { user } = renderTab({ hasResults: true })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Create' }))
|
||||
|
||||
expect(execute).toHaveBeenCalledWith('Comfy.NewBlankWorkflow')
|
||||
})
|
||||
|
||||
it('runs the new-workflow command from the empty-state action', async () => {
|
||||
const { user } = renderTab({ hasResults: false })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Create app' }))
|
||||
|
||||
expect(execute).toHaveBeenCalledWith('Comfy.NewBlankWorkflow')
|
||||
})
|
||||
})
|
||||
@@ -13,18 +13,26 @@
|
||||
{{ $t('g.beta') }}
|
||||
</span>
|
||||
</template>
|
||||
<template #header-actions="{ hasResults }">
|
||||
<Button
|
||||
v-if="hasResults"
|
||||
variant="secondary"
|
||||
size="md"
|
||||
:aria-label="$t('linearMode.appModeToolbar.create')"
|
||||
@click="createApp"
|
||||
>
|
||||
<i class="icon-[lucide--plus] size-4" aria-hidden="true" />
|
||||
{{ $t('linearMode.appModeToolbar.create') }}
|
||||
</Button>
|
||||
</template>
|
||||
<template #empty-state>
|
||||
<NoResultsPlaceholder
|
||||
button-variant="secondary"
|
||||
text-class="text-muted-foreground text-sm"
|
||||
:message="
|
||||
isAppMode
|
||||
? $t('linearMode.appModeToolbar.appsEmptyMessage')
|
||||
: `${$t('linearMode.appModeToolbar.appsEmptyMessage')}\n${$t('linearMode.appModeToolbar.appsEmptyMessageAction')}`
|
||||
"
|
||||
button-icon="icon-[lucide--hammer]"
|
||||
:button-label="isAppMode ? undefined : $t('linearMode.buildAnApp')"
|
||||
@action="enterAppMode"
|
||||
:message="`${$t('linearMode.appModeToolbar.appsEmptyMessage')}\n${$t('linearMode.appModeToolbar.appsEmptyMessageAction')}`"
|
||||
button-icon="icon-[lucide--plus]"
|
||||
:button-label="$t('linearMode.appModeToolbar.createApp')"
|
||||
@action="createApp"
|
||||
/>
|
||||
</template>
|
||||
</BaseWorkflowsSidebarTab>
|
||||
@@ -33,16 +41,17 @@
|
||||
<script setup lang="ts">
|
||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
import BaseWorkflowsSidebarTab from '@/components/sidebar/tabs/BaseWorkflowsSidebarTab.vue'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
|
||||
const { isAppMode, setMode } = useAppMode()
|
||||
const commandStore = useCommandStore()
|
||||
|
||||
function isAppWorkflow(workflow: ComfyWorkflow): boolean {
|
||||
return workflow.suffix === 'app.json'
|
||||
}
|
||||
|
||||
function enterAppMode() {
|
||||
setMode('app')
|
||||
function createApp() {
|
||||
void commandStore.execute('Comfy.NewBlankWorkflow')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -30,6 +30,10 @@
|
||||
"
|
||||
/>
|
||||
</Button>
|
||||
<slot
|
||||
name="header-actions"
|
||||
:has-results="filteredPersistedWorkflows.length > 0"
|
||||
/>
|
||||
</template>
|
||||
<template #header>
|
||||
<SidebarTopArea>
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
|
||||
<!-- Credits Section -->
|
||||
<div v-if="isActiveSubscription" class="flex items-center gap-2 px-4 py-2">
|
||||
<i class="icon-[lucide--component] text-sm text-amber-400" />
|
||||
<i :class="cn(CREDITS_ICON, 'text-sm text-amber-400')" />
|
||||
<Skeleton v-if="isLoading" width="4rem" height="1.25rem" class="w-full" />
|
||||
<span v-else class="text-base font-semibold text-base-foreground">{{
|
||||
formattedBalance
|
||||
@@ -153,7 +153,12 @@ import Skeleton from 'primevue/skeleton'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import {
|
||||
CREDITS_ICON,
|
||||
formatCreditsFromCents
|
||||
} from '@/base/credits/comfyCredits'
|
||||
import UserAvatar from '@/components/common/UserAvatar.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { breakpointsTailwind, useBreakpoints, whenever } from '@vueuse/core'
|
||||
import { useTemplateRef } from 'vue'
|
||||
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
const { active = true } = defineProps<{
|
||||
dataTfWidget: string
|
||||
active?: boolean
|
||||
}>()
|
||||
|
||||
const feedbackRef = useTemplateRef('feedbackRef')
|
||||
const isMobile = useBreakpoints(breakpointsTailwind).smaller('md')
|
||||
|
||||
whenever(feedbackRef, () => {
|
||||
const scriptEl = document.createElement('script')
|
||||
scriptEl.src = '//embed.typeform.com/next/embed.js'
|
||||
feedbackRef.value?.appendChild(scriptEl)
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<Button
|
||||
v-if="isMobile"
|
||||
as="a"
|
||||
:href="`https://form.typeform.com/to/${dataTfWidget}`"
|
||||
target="_blank"
|
||||
variant="inverted"
|
||||
class="flex h-10 items-center justify-center gap-2.5 px-3 py-2"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<i class="icon-[lucide--circle-help] size-4" />
|
||||
</Button>
|
||||
<Popover v-else>
|
||||
<template #button>
|
||||
<Button
|
||||
variant="inverted"
|
||||
class="flex h-10 items-center justify-center gap-2.5 px-3 py-2"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<i class="icon-[lucide--circle-help] size-4" />
|
||||
</Button>
|
||||
</template>
|
||||
<div v-if="active" ref="feedbackRef" data-tf-auto-resize :data-tf-widget />
|
||||
</Popover>
|
||||
</template>
|
||||
@@ -25,7 +25,8 @@ export const buttonVariants = cva({
|
||||
tertiary:
|
||||
'bg-tertiary-background text-base-foreground hover:bg-tertiary-background-hover',
|
||||
gradient:
|
||||
'border-transparent bg-(image:--subscription-button-gradient) text-white hover:opacity-90'
|
||||
'border-transparent bg-(image:--subscription-button-gradient) text-white hover:opacity-90',
|
||||
'brand-yellow': 'bg-brand-yellow text-black hover:bg-brand-yellow/85'
|
||||
},
|
||||
size: {
|
||||
sm: 'h-6 rounded-sm px-2 py-1 text-xs',
|
||||
@@ -58,7 +59,8 @@ const variants = [
|
||||
'base',
|
||||
'tertiary',
|
||||
'overlay-white',
|
||||
'gradient'
|
||||
'gradient',
|
||||
'brand-yellow'
|
||||
] as const satisfies Array<ButtonVariants['variant']>
|
||||
const sizes = [
|
||||
'sm',
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import { CREDITS_ICON } from '@/base/credits/comfyCredits'
|
||||
import Slider from '@/components/ui/slider/Slider.vue'
|
||||
import {
|
||||
DEFAULT_TEAM_PLAN_STOP_INDEX,
|
||||
@@ -223,7 +224,8 @@ const { t } = useI18n()
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
'icon-[comfy--credits] size-3 shrink-0',
|
||||
CREDITS_ICON,
|
||||
'size-3 shrink-0',
|
||||
i === selectedIndex ? 'bg-amber-400' : 'bg-muted-foreground'
|
||||
)
|
||||
"
|
||||
|
||||
@@ -16,14 +16,12 @@ import {
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
|
||||
import {
|
||||
appendQuarantine,
|
||||
flushProxyWidgetMigration,
|
||||
normalizeLegacyProxyWidgetEntry,
|
||||
readHostQuarantine
|
||||
} from '@/core/graph/subgraph/migration/proxyWidgetMigration'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
import { toLinkId } from '@/types/linkId'
|
||||
import { UNASSIGNED_NODE_ID, toNodeId } from '@/types/nodeId'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
@@ -181,33 +179,6 @@ describe('flushProxyWidgetMigration', () => {
|
||||
expect(getPromotedInputValue(outerHost, 'text')).toBe('22222222222')
|
||||
})
|
||||
|
||||
it('createSubgraphInput: resolves a nested promoted input by host input name', () => {
|
||||
const rootGraph = new LGraph()
|
||||
const innerSubgraph = createTestSubgraph({ rootGraph })
|
||||
const source = new LGraphNode('CLIPTextEncode')
|
||||
const sourceSlot = source.addInput('text', 'STRING')
|
||||
sourceSlot.widget = { name: 'text' }
|
||||
source.addWidget('text', 'text', 'nested value', () => {})
|
||||
innerSubgraph.add(source)
|
||||
|
||||
const nestedHost = createTestSubgraphNode(innerSubgraph, {
|
||||
parentGraph: rootGraph
|
||||
})
|
||||
nestedHost.properties.proxyWidgets = [[String(source.id), 'text']]
|
||||
flushProxyWidgetMigration({ hostNode: nestedHost })
|
||||
|
||||
const outerSubgraph = createTestSubgraph({ rootGraph })
|
||||
outerSubgraph.add(nestedHost)
|
||||
const outerHost = createTestSubgraphNode(outerSubgraph, {
|
||||
parentGraph: rootGraph
|
||||
})
|
||||
outerHost.properties.proxyWidgets = [[String(nestedHost.id), 'text']]
|
||||
|
||||
flushProxyWidgetMigration({ hostNode: outerHost })
|
||||
|
||||
expect(getPromotedInputValue(outerHost, 'text')).toBe('nested value')
|
||||
})
|
||||
|
||||
it('alreadyLinked: leaves widget value unchanged when host value is a sparse hole', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'seed', type: 'INT' }]
|
||||
@@ -269,41 +240,6 @@ describe('flushProxyWidgetMigration', () => {
|
||||
).toBe('renamed_from_sidepanel')
|
||||
})
|
||||
|
||||
it('createSubgraphInput: falls back to the source widget type when the slot type is missing', () => {
|
||||
const host = buildHost()
|
||||
const inner = addInnerNode(host, 'Inner', (n) => {
|
||||
const slot = n.addInput('seed', 'INT')
|
||||
slot.type = undefined as never
|
||||
slot.widget = { name: 'seed' }
|
||||
n.addWidget('number', 'seed', 0, () => {})
|
||||
})
|
||||
|
||||
host.properties.proxyWidgets = [[String(inner.id), 'seed']]
|
||||
flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
expect(
|
||||
host.subgraph.inputs.find((input) => input.name === 'seed')?.type
|
||||
).toBe('number')
|
||||
})
|
||||
|
||||
it('createSubgraphInput: falls back to wildcard type when slot and widget type are missing', () => {
|
||||
const host = buildHost()
|
||||
const inner = addInnerNode(host, 'Inner', (n) => {
|
||||
const slot = n.addInput('seed', 'INT')
|
||||
slot.type = undefined as never
|
||||
slot.widget = { name: 'seed' }
|
||||
const widget = n.addWidget('number', 'seed', 0, () => {})
|
||||
widget.type = undefined as never
|
||||
})
|
||||
|
||||
host.properties.proxyWidgets = [[String(inner.id), 'seed']]
|
||||
flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
expect(
|
||||
host.subgraph.inputs.find((input) => input.name === 'seed')?.type
|
||||
).toBe('*')
|
||||
})
|
||||
|
||||
it('createSubgraphInput: quarantines missingSubgraphInput when source widget has no backing input slot', () => {
|
||||
const host = buildHost()
|
||||
const inner = addInnerNode(host, 'Inner', (n) => {
|
||||
@@ -392,88 +328,6 @@ describe('flushProxyWidgetMigration', () => {
|
||||
expect(getPromotedInputValue(host, 'value')).toBe(11)
|
||||
})
|
||||
|
||||
it('uses the primitive title as the promoted input name when it was renamed', () => {
|
||||
const host = buildHost()
|
||||
const { primitive } = addPrimitiveWithTargets(host, {
|
||||
targetCount: 1
|
||||
})
|
||||
primitive.title = 'Batch Size'
|
||||
|
||||
host.properties.proxyWidgets = [[String(primitive.id), 'value']]
|
||||
flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
expect(
|
||||
host.inputs.find((input) => input.name === 'Batch Size')
|
||||
).toBeDefined()
|
||||
})
|
||||
|
||||
it('skips a stale primitive bypass marker when the host input is absent', () => {
|
||||
const host = buildHost()
|
||||
const { primitive, targets } = addPrimitiveWithTargets(host, {
|
||||
targetCount: 1
|
||||
})
|
||||
primitive.properties = {
|
||||
proxyBypassedToSubgraphInput: 'deleted_input'
|
||||
}
|
||||
|
||||
host.properties.proxyWidgets = [[String(primitive.id), 'value']]
|
||||
flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
const slot = targets[0].inputs[0]
|
||||
const link = host.subgraph.links.get(slot.link!)
|
||||
expect(link?.origin_id).not.toBe(primitive.id)
|
||||
expect(host.inputs.find((input) => input.name === 'value')).toBeDefined()
|
||||
})
|
||||
|
||||
it('quarantines a stale primitive bypass marker that points to a plain input', () => {
|
||||
const host = buildHost()
|
||||
const { primitive } = addPrimitiveWithTargets(host, {
|
||||
targetCount: 1
|
||||
})
|
||||
primitive.properties = {
|
||||
proxyBypassedToSubgraphInput: 'plain'
|
||||
}
|
||||
host.addInput('plain', 'INT')
|
||||
|
||||
host.properties.proxyWidgets = [[String(primitive.id), 'value']]
|
||||
flushProxyWidgetMigration({
|
||||
hostNode: host,
|
||||
hostWidgetValues: [12]
|
||||
})
|
||||
|
||||
expect(readHostQuarantine(host)).toEqual([
|
||||
expect.objectContaining({
|
||||
originalEntry: [String(primitive.id), 'value'],
|
||||
reason: 'missingSubgraphInput'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('quarantines a stale primitive bypass marker that matches ambiguous host inputs', () => {
|
||||
const host = buildHost()
|
||||
const { primitive } = addPrimitiveWithTargets(host, {
|
||||
targetCount: 1
|
||||
})
|
||||
primitive.properties = {
|
||||
proxyBypassedToSubgraphInput: 'plain'
|
||||
}
|
||||
host.addInput('plain', 'INT')
|
||||
host.addInput('plain', 'INT')
|
||||
|
||||
host.properties.proxyWidgets = [[String(primitive.id), 'value']]
|
||||
flushProxyWidgetMigration({
|
||||
hostNode: host,
|
||||
hostWidgetValues: [12]
|
||||
})
|
||||
|
||||
expect(readHostQuarantine(host)).toEqual([
|
||||
expect.objectContaining({
|
||||
originalEntry: [String(primitive.id), 'value'],
|
||||
reason: 'ambiguousSubgraphInput'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('quarantines an unlinked primitive node with no fan-out', () => {
|
||||
const host = buildHost()
|
||||
const primitive = new LGraphNode('Primitive')
|
||||
@@ -492,64 +346,6 @@ describe('flushProxyWidgetMigration', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('quarantines primitive cohorts that disagree on source widget name', () => {
|
||||
const host = buildHost()
|
||||
const { primitive } = addPrimitiveWithTargets(host, {
|
||||
targetCount: 1
|
||||
})
|
||||
|
||||
host.properties.proxyWidgets = [
|
||||
[String(primitive.id), 'value'],
|
||||
[String(primitive.id), 'other']
|
||||
]
|
||||
flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
expect(readHostQuarantine(host)).toEqual([
|
||||
expect.objectContaining({
|
||||
originalEntry: [String(primitive.id), 'value'],
|
||||
reason: 'primitiveBypassFailed'
|
||||
}),
|
||||
expect.objectContaining({
|
||||
originalEntry: [String(primitive.id), 'other'],
|
||||
reason: 'primitiveBypassFailed'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('quarantines duplicate primitive entries with no fan-out targets', () => {
|
||||
const host = buildHost()
|
||||
const primitive = new LGraphNode('PrimitiveNode')
|
||||
primitive.type = 'PrimitiveNode'
|
||||
primitive.addOutput('value', 'INT')
|
||||
host.subgraph.add(primitive)
|
||||
|
||||
host.properties.proxyWidgets = [
|
||||
[String(primitive.id), 'value'],
|
||||
[String(primitive.id), 'value']
|
||||
]
|
||||
flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
expect(readHostQuarantine(host)).toEqual([
|
||||
expect.objectContaining({
|
||||
originalEntry: [String(primitive.id), 'value'],
|
||||
reason: 'primitiveBypassFailed'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps the target default when the primitive source widget has no value', () => {
|
||||
const host = buildHost()
|
||||
const { primitive } = addPrimitiveWithTargets(host, {
|
||||
targetCount: 1
|
||||
})
|
||||
primitive.widgets = []
|
||||
|
||||
host.properties.proxyWidgets = [[String(primitive.id), 'value']]
|
||||
flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
expect(getPromotedInputValue(host, 'value')).toBe(0)
|
||||
})
|
||||
|
||||
it('quarantines all cohort entries when a target slot type is incompatible', () => {
|
||||
const host = buildHost()
|
||||
const { primitive, targets } = addPrimitiveWithTargets(host, {
|
||||
@@ -570,73 +366,6 @@ describe('flushProxyWidgetMigration', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('quarantines primitive repair when the target slot disappeared', () => {
|
||||
const host = buildHost()
|
||||
const { primitive, targets } = addPrimitiveWithTargets(host, {
|
||||
targetCount: 1
|
||||
})
|
||||
targets[0].inputs = []
|
||||
|
||||
const inputCountBefore = host.subgraph.inputs.length
|
||||
host.properties.proxyWidgets = [[String(primitive.id), 'value']]
|
||||
flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
expect(host.subgraph.inputs).toHaveLength(inputCountBefore)
|
||||
expect(readHostQuarantine(host)).toEqual([
|
||||
expect.objectContaining({
|
||||
originalEntry: [String(primitive.id), 'value'],
|
||||
reason: 'primitiveBypassFailed'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('quarantines primitive repair when the target node id is stale', () => {
|
||||
const host = buildHost()
|
||||
const { primitive } = addPrimitiveWithTargets(host, {
|
||||
targetCount: 1
|
||||
})
|
||||
const linkId = primitive.outputs[0].links?.[0]
|
||||
if (!linkId) throw new Error('Missing primitive link')
|
||||
const link = host.subgraph.links.get(linkId)
|
||||
if (!link) throw new Error('Missing primitive link record')
|
||||
link.target_id = toNodeId(999_999)
|
||||
|
||||
host.properties.proxyWidgets = [[String(primitive.id), 'value']]
|
||||
flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
expect(readHostQuarantine(host)).toEqual([
|
||||
expect.objectContaining({
|
||||
originalEntry: [String(primitive.id), 'value'],
|
||||
reason: 'primitiveBypassFailed'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('quarantines duplicate primitive entries when the fan-out target is unassigned', () => {
|
||||
const host = buildHost()
|
||||
const { primitive } = addPrimitiveWithTargets(host, {
|
||||
targetCount: 1
|
||||
})
|
||||
const linkId = primitive.outputs[0].links?.[0]
|
||||
if (!linkId) throw new Error('Missing primitive link')
|
||||
const link = host.subgraph.links.get(linkId)
|
||||
if (!link) throw new Error('Missing primitive link record')
|
||||
link.target_id = UNASSIGNED_NODE_ID
|
||||
|
||||
host.properties.proxyWidgets = [
|
||||
[String(primitive.id), 'value'],
|
||||
[String(primitive.id), 'value']
|
||||
]
|
||||
flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
expect(readHostQuarantine(host)).toEqual([
|
||||
expect.objectContaining({
|
||||
originalEntry: [String(primitive.id), 'value'],
|
||||
reason: 'primitiveBypassFailed'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps surviving primitive targets when one fan-out link is dangling', () => {
|
||||
const host = buildHost()
|
||||
const { primitive } = addPrimitiveWithTargets(host, { targetCount: 1 })
|
||||
@@ -843,22 +572,6 @@ describe('flushProxyWidgetMigration', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('does not preserve non-widget host values on quarantine rows', () => {
|
||||
const host = buildHost()
|
||||
host.properties.proxyWidgets = [['9999', 'seed']]
|
||||
|
||||
flushProxyWidgetMigration({
|
||||
hostNode: host,
|
||||
hostWidgetValues: [null]
|
||||
})
|
||||
|
||||
expect(readHostQuarantine(host)).toEqual([
|
||||
expect.not.objectContaining({
|
||||
hostValue: expect.anything()
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('round-trips appended entries via the public read helper', () => {
|
||||
const host = buildHost()
|
||||
host.properties.proxyWidgets = [['9999', 'seed']]
|
||||
@@ -889,14 +602,6 @@ describe('flushProxyWidgetMigration', () => {
|
||||
|
||||
expect(readHostQuarantine(host)).toEqual(firstQuarantine)
|
||||
})
|
||||
|
||||
it('ignores empty quarantine append requests', () => {
|
||||
const host = buildHost()
|
||||
|
||||
appendQuarantine(host, [])
|
||||
|
||||
expect(host.properties.proxyWidgetErrorQuarantine).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('idempotency', () => {
|
||||
@@ -1119,22 +824,6 @@ describe('normalizeLegacyProxyWidgetEntry', () => {
|
||||
expect(result.disambiguatingSourceNodeId).toBe(String(samplerNode.id))
|
||||
})
|
||||
|
||||
it('strips nested legacy prefixes from widget name', () => {
|
||||
const { hostNode, innerNode } = createHostWithInnerWidget('seed')
|
||||
|
||||
const result = normalizeLegacyProxyWidgetEntry(
|
||||
hostNode,
|
||||
String(innerNode.id),
|
||||
'111: 222: seed'
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
sourceNodeId: String(innerNode.id),
|
||||
sourceWidgetName: 'seed',
|
||||
disambiguatingSourceNodeId: '222'
|
||||
})
|
||||
})
|
||||
|
||||
it('strips legacy prefix and surfaces it as disambiguator even when the bare name does not resolve', () => {
|
||||
const { hostNode, innerNode } = createHostWithInnerWidget('seed')
|
||||
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { WidgetId } from '@/types/widgetId'
|
||||
|
||||
import {
|
||||
inputForWidget,
|
||||
promotedInputSource,
|
||||
promotedInputWidget,
|
||||
promotedInputWidgets,
|
||||
widgetPromotedSource
|
||||
} from './promotedInputWidget'
|
||||
import { resolveSubgraphInputTarget } from './resolveSubgraphInputTarget'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
widgets: new Map<string, Record<string, unknown>>(),
|
||||
setValue: vi.fn(),
|
||||
resolveSubgraphInputTarget: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/widgetValueStore', () => ({
|
||||
useWidgetValueStore: () => ({
|
||||
getWidget: (id: string) => mocks.widgets.get(id),
|
||||
setValue: mocks.setValue
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('./resolveSubgraphInputTarget', () => ({
|
||||
resolveSubgraphInputTarget: mocks.resolveSubgraphInputTarget
|
||||
}))
|
||||
|
||||
function input(overrides: Partial<INodeInputSlot> = {}): INodeInputSlot {
|
||||
return {
|
||||
name: 'prompt',
|
||||
type: 'STRING',
|
||||
label: 'Prompt',
|
||||
...overrides
|
||||
} as INodeInputSlot
|
||||
}
|
||||
|
||||
function node(overrides: Record<string, unknown> = {}): LGraphNode {
|
||||
return {
|
||||
inputs: [],
|
||||
isSubgraphNode: () => true,
|
||||
getSlotFromWidget: vi.fn(),
|
||||
...overrides
|
||||
} as unknown as LGraphNode
|
||||
}
|
||||
|
||||
describe('promotedInputWidget helpers', () => {
|
||||
beforeEach(() => {
|
||||
mocks.widgets.clear()
|
||||
mocks.setValue.mockClear()
|
||||
mocks.resolveSubgraphInputTarget.mockReset()
|
||||
})
|
||||
|
||||
it('resolves promoted input sources only for widget-backed inputs', () => {
|
||||
const graphNode = node()
|
||||
mocks.resolveSubgraphInputTarget.mockReturnValue({
|
||||
nodeId: '12',
|
||||
widgetName: 'prompt'
|
||||
})
|
||||
|
||||
expect(promotedInputSource(graphNode, input())).toBeUndefined()
|
||||
expect(
|
||||
promotedInputSource(
|
||||
graphNode,
|
||||
input({ widgetId: 'graph:12:prompt' as WidgetId })
|
||||
)
|
||||
).toEqual({
|
||||
nodeId: '12',
|
||||
widgetName: 'prompt'
|
||||
})
|
||||
expect(resolveSubgraphInputTarget).toHaveBeenCalledWith(graphNode, 'prompt')
|
||||
})
|
||||
|
||||
it('resolves promoted widget sources only on subgraph nodes with matching inputs', () => {
|
||||
const widget = { name: 'prompt' } as IBaseWidget
|
||||
const backingInput = input({ widgetId: 'graph:12:prompt' as WidgetId })
|
||||
mocks.resolveSubgraphInputTarget.mockReturnValue({
|
||||
nodeId: '12',
|
||||
widgetName: 'prompt'
|
||||
})
|
||||
|
||||
expect(
|
||||
widgetPromotedSource(node({ isSubgraphNode: () => false }), widget)
|
||||
).toBeUndefined()
|
||||
expect(
|
||||
widgetPromotedSource(node({ getSlotFromWidget: () => undefined }), widget)
|
||||
).toBeUndefined()
|
||||
expect(
|
||||
widgetPromotedSource(
|
||||
node({ getSlotFromWidget: () => backingInput }),
|
||||
widget
|
||||
)
|
||||
).toEqual({
|
||||
nodeId: '12',
|
||||
widgetName: 'prompt'
|
||||
})
|
||||
})
|
||||
|
||||
it('projects store-backed widget fields with input fallbacks', () => {
|
||||
const widgetId = 'graph:12:prompt' as WidgetId
|
||||
const widget = promotedInputWidget(input({ widgetId }))
|
||||
|
||||
expect(widget?.name).toBe('prompt')
|
||||
expect(widget?.label).toBe('Prompt')
|
||||
expect(widget?.y).toBe(0)
|
||||
expect(widget?.type).toBe('text')
|
||||
expect(widget?.options).toEqual({})
|
||||
expect(widget?.value).toBeUndefined()
|
||||
|
||||
widget!.label = 'Ignored'
|
||||
widget!.y = 12
|
||||
widget!.value = 'next'
|
||||
widget!.callback?.('callback')
|
||||
|
||||
expect(mocks.setValue).toHaveBeenCalledWith(widgetId, 'next')
|
||||
expect(mocks.setValue).toHaveBeenCalledWith(widgetId, 'callback')
|
||||
})
|
||||
|
||||
it('projects live widget store fields and mutates store state', () => {
|
||||
const widgetId = 'graph:12:prompt' as WidgetId
|
||||
const state = {
|
||||
name: 'store-name',
|
||||
label: 'Store Label',
|
||||
y: 42,
|
||||
type: 'combo',
|
||||
options: { values: ['a'] },
|
||||
value: 'a'
|
||||
}
|
||||
mocks.widgets.set(widgetId, state)
|
||||
|
||||
const widget = promotedInputWidget(input({ widgetId, label: undefined }))
|
||||
|
||||
expect(widget?.name).toBe('store-name')
|
||||
expect(widget?.label).toBe('Store Label')
|
||||
expect(widget?.y).toBe(42)
|
||||
expect(widget?.type).toBe('combo')
|
||||
expect(widget?.options).toEqual({ values: ['a'] })
|
||||
expect(widget?.value).toBe('a')
|
||||
|
||||
widget!.label = 'New Label'
|
||||
widget!.y = 52
|
||||
|
||||
expect(state.label).toBe('New Label')
|
||||
expect(state.y).toBe(52)
|
||||
})
|
||||
|
||||
it('returns null for non-promoted inputs and filters projected widget lists', () => {
|
||||
const widgetId = 'graph:12:prompt' as WidgetId
|
||||
const graphNode = node({
|
||||
inputs: [input(), input({ widgetId })]
|
||||
})
|
||||
|
||||
expect(promotedInputWidget(input())).toBeNull()
|
||||
expect(promotedInputWidgets(graphNode)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('returns undefined for null stored values', () => {
|
||||
const widgetId = 'graph:12:prompt' as WidgetId
|
||||
mocks.widgets.set(widgetId, { value: null })
|
||||
|
||||
expect(promotedInputWidget(input({ widgetId }))?.value).toBeUndefined()
|
||||
})
|
||||
|
||||
it('delegates input lookup to the graph node', () => {
|
||||
const widget = { name: 'prompt' } as IBaseWidget
|
||||
const backingInput = input({ widgetId: 'graph:12:prompt' as WidgetId })
|
||||
const graphNode = node({
|
||||
getSlotFromWidget: vi.fn(() => backingInput)
|
||||
})
|
||||
|
||||
expect(inputForWidget(graphNode, widget)).toBe(backingInput)
|
||||
expect(graphNode.getSlotFromWidget).toHaveBeenCalledWith(widget)
|
||||
})
|
||||
})
|
||||
@@ -15,10 +15,6 @@ import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { toLinkId } from '@/types/linkId'
|
||||
import type { WidgetId } from '@/types/widgetId'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import type { Subgraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
function promotedInputNames(host: {
|
||||
inputs: Array<{ widgetId?: unknown; name: string }>
|
||||
@@ -55,37 +51,19 @@ vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => ({ updatePreviews: updatePreviewsMock })
|
||||
}))
|
||||
|
||||
const addBreadcrumbMock = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@sentry/vue', () => ({
|
||||
addBreadcrumb: addBreadcrumbMock
|
||||
}))
|
||||
|
||||
const mockNavigation = vi.hoisted(() => ({
|
||||
stack: [] as Subgraph[]
|
||||
}))
|
||||
vi.mock('@/stores/subgraphNavigationStore', () => ({
|
||||
useSubgraphNavigationStore: () => ({
|
||||
navigationStack: mockNavigation.stack
|
||||
})
|
||||
}))
|
||||
|
||||
import {
|
||||
CANVAS_IMAGE_PREVIEW_WIDGET,
|
||||
addWidgetPromotionOptions,
|
||||
autoExposeKnownPreviewNodes,
|
||||
demoteWidget,
|
||||
getPromotableWidgets,
|
||||
hasUnpromotedWidgets,
|
||||
isLinkedPromotion,
|
||||
isPreviewPseudoWidget,
|
||||
isWidgetPromotedOnSubgraphNode,
|
||||
promoteWidget,
|
||||
promoteValueWidgetViaSubgraphInput,
|
||||
promoteRecommendedWidgets,
|
||||
pruneDisconnected,
|
||||
reorderSubgraphInputsByName,
|
||||
reorderSubgraphInputsByWidgetOrder,
|
||||
tryToggleWidgetPromotion
|
||||
reorderSubgraphInputsByWidgetOrder
|
||||
} from './promotionUtils'
|
||||
|
||||
function widget(
|
||||
@@ -96,6 +74,11 @@ function widget(
|
||||
return fromPartial<IBaseWidget>({ name: 'widget', ...overrides })
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a host SubgraphNode whose subgraph contains two source nodes that
|
||||
* share a widget name (`text`), then promotes both — forcing the second
|
||||
* promotion to be disambiguated to `text_1`.
|
||||
*/
|
||||
function buildDuplicateNamePromotion() {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
@@ -119,11 +102,6 @@ function buildDuplicateNamePromotion() {
|
||||
return { subgraph, host, nodeA, widgetA, nodeB, widgetB }
|
||||
}
|
||||
|
||||
function setupNavigation(host: SubgraphNode) {
|
||||
host.subgraph.rootGraph.add(host)
|
||||
mockNavigation.stack = [host.subgraph]
|
||||
}
|
||||
|
||||
describe('isPreviewPseudoWidget', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
@@ -325,284 +303,6 @@ describe('getPromotableWidgets', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('widget promotion actions', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
addBreadcrumbMock.mockReset()
|
||||
mockNavigation.stack = []
|
||||
})
|
||||
|
||||
function setupPromotableWidget() {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
setupNavigation(host)
|
||||
const node = new LGraphNode('Prompt')
|
||||
subgraph.add(node)
|
||||
const input = node.addInput('text', 'STRING')
|
||||
input.label = 'Prompt text'
|
||||
const callback = vi.fn()
|
||||
const textWidget = node.addWidget('text', 'text', 'value', callback)
|
||||
textWidget.label = 'Prompt'
|
||||
input.widget = { name: textWidget.name }
|
||||
return { host, node, textWidget, callback }
|
||||
}
|
||||
|
||||
it('adds a promote menu option and runs the widget callback after promotion', () => {
|
||||
const { host, node, textWidget, callback } = setupPromotableWidget()
|
||||
const options: Parameters<typeof addWidgetPromotionOptions>[0] = []
|
||||
|
||||
addWidgetPromotionOptions(options, textWidget, node)
|
||||
const menuCallback = options[0]?.callback as
|
||||
| ((...args: unknown[]) => unknown)
|
||||
| undefined
|
||||
void menuCallback?.(null, undefined, undefined)
|
||||
|
||||
expect(options[0]?.content).toContain('Prompt')
|
||||
expect(isLinkedPromotion(host, String(node.id), textWidget.name)).toBe(true)
|
||||
expect(callback).toHaveBeenCalledWith('value')
|
||||
})
|
||||
|
||||
it('adds an unpromote menu option when the widget is already promoted', () => {
|
||||
const { host, node, textWidget, callback } = setupPromotableWidget()
|
||||
expect(promoteValueWidgetViaSubgraphInput(host, node, textWidget).ok).toBe(
|
||||
true
|
||||
)
|
||||
const options: Parameters<typeof addWidgetPromotionOptions>[0] = []
|
||||
|
||||
addWidgetPromotionOptions(options, textWidget, node)
|
||||
const menuCallback = options[0]?.callback as
|
||||
| ((...args: unknown[]) => unknown)
|
||||
| undefined
|
||||
void menuCallback?.(null, undefined, undefined)
|
||||
|
||||
expect(isLinkedPromotion(host, String(node.id), textWidget.name)).toBe(
|
||||
false
|
||||
)
|
||||
expect(callback).toHaveBeenCalledWith('value')
|
||||
})
|
||||
|
||||
it('reports outside-subgraph promotion attempts through the toast store', () => {
|
||||
const node = new LGraphNode('Prompt')
|
||||
const textWidget = node.addWidget('text', 'text', 'value', () => {})
|
||||
const options: Parameters<typeof addWidgetPromotionOptions>[0] = []
|
||||
|
||||
addWidgetPromotionOptions(options, textWidget, node)
|
||||
|
||||
expect(useToastStore().messagesToAdd).toHaveLength(1)
|
||||
expect(options).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('toggles promotion for the widget under the canvas pointer', () => {
|
||||
const { host, node, textWidget } = setupPromotableWidget()
|
||||
const canvas = fromPartial<ReturnType<typeof useCanvasStore>['canvas']>({
|
||||
graph_mouse: [10, 20],
|
||||
visible_nodes: [node],
|
||||
setDirty: vi.fn(),
|
||||
graph: {
|
||||
getNodeOnPos: vi.fn(() => node)
|
||||
}
|
||||
})
|
||||
vi.spyOn(node, 'getWidgetOnPos').mockReturnValue(textWidget)
|
||||
useCanvasStore().canvas = canvas
|
||||
|
||||
tryToggleWidgetPromotion()
|
||||
expect(isLinkedPromotion(host, String(node.id), textWidget.name)).toBe(true)
|
||||
|
||||
tryToggleWidgetPromotion()
|
||||
expect(isLinkedPromotion(host, String(node.id), textWidget.name)).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves state unchanged when toggle has no node or widget target', () => {
|
||||
const { host, node, textWidget } = setupPromotableWidget()
|
||||
useCanvasStore().canvas = fromPartial<
|
||||
ReturnType<typeof useCanvasStore>['canvas']
|
||||
>({
|
||||
graph_mouse: [0, 0],
|
||||
visible_nodes: [],
|
||||
setDirty: vi.fn(),
|
||||
graph: {
|
||||
getNodeOnPos: vi.fn(() => null)
|
||||
}
|
||||
})
|
||||
|
||||
tryToggleWidgetPromotion()
|
||||
expect(isLinkedPromotion(host, String(node.id), textWidget.name)).toBe(
|
||||
false
|
||||
)
|
||||
|
||||
useCanvasStore().canvas = fromPartial<
|
||||
ReturnType<typeof useCanvasStore>['canvas']
|
||||
>({
|
||||
graph_mouse: [0, 0],
|
||||
visible_nodes: [node],
|
||||
setDirty: vi.fn(),
|
||||
graph: {
|
||||
getNodeOnPos: vi.fn(() => node)
|
||||
}
|
||||
})
|
||||
vi.spyOn(node, 'getWidgetOnPos').mockReturnValue(undefined)
|
||||
|
||||
tryToggleWidgetPromotion()
|
||||
expect(isLinkedPromotion(host, String(node.id), textWidget.name)).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('records a breadcrumb when value promotion has no source slot', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const node = new LGraphNode('LooseWidgetNode')
|
||||
subgraph.add(node)
|
||||
const looseWidget = node.addWidget('text', 'loose', 'value', () => {})
|
||||
|
||||
promoteWidget(node, looseWidget, [host])
|
||||
|
||||
expect(addBreadcrumbMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
level: 'warning',
|
||||
message: expect.stringContaining('missingSourceSlot')
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('ignores promotion calls for node-shaped values that are not graph nodes', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const partialNode = {
|
||||
id: toNodeId(123),
|
||||
title: 'Partial',
|
||||
type: 'Partial'
|
||||
}
|
||||
|
||||
promoteWidget(partialNode, widget({ name: 'seed', type: 'number' }), [host])
|
||||
|
||||
expect(host.subgraph.inputs).toEqual([])
|
||||
expect(addBreadcrumbMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses the widget name in menu text when label is absent', () => {
|
||||
const { node, textWidget } = setupPromotableWidget()
|
||||
textWidget.label = undefined
|
||||
const options: Parameters<typeof addWidgetPromotionOptions>[0] = []
|
||||
|
||||
addWidgetPromotionOptions(options, textWidget, node)
|
||||
|
||||
expect(options[0]?.content).toContain('text')
|
||||
})
|
||||
})
|
||||
|
||||
describe('preview promotion actions', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
addBreadcrumbMock.mockReset()
|
||||
mockNavigation.stack = []
|
||||
})
|
||||
|
||||
it('identifies preview exposure as promotion only for preview pseudo widgets', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const previewNode = new LGraphNode('PreviewImage')
|
||||
previewNode.type = 'PreviewImage'
|
||||
subgraph.add(previewNode)
|
||||
const previewWidget = widget({
|
||||
name: CANVAS_IMAGE_PREVIEW_WIDGET,
|
||||
serialize: false,
|
||||
type: 'preview'
|
||||
})
|
||||
usePreviewExposureStore().addExposure(host.rootGraph.id, String(host.id), {
|
||||
sourceNodeId: previewNode.id,
|
||||
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
|
||||
})
|
||||
|
||||
expect(
|
||||
isWidgetPromotedOnSubgraphNode(
|
||||
host,
|
||||
{
|
||||
sourceNodeId: previewNode.id,
|
||||
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET
|
||||
},
|
||||
previewWidget
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
isWidgetPromotedOnSubgraphNode(
|
||||
host,
|
||||
{
|
||||
sourceNodeId: previewNode.id,
|
||||
sourceWidgetName: 'other'
|
||||
},
|
||||
previewWidget
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('deduplicates preview exposures when the same preview is promoted twice', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const previewNode = new LGraphNode('PreviewImage')
|
||||
previewNode.type = 'PreviewImage'
|
||||
subgraph.add(previewNode)
|
||||
const previewWidget = widget({
|
||||
name: CANVAS_IMAGE_PREVIEW_WIDGET,
|
||||
serialize: false,
|
||||
type: 'preview'
|
||||
})
|
||||
|
||||
promoteWidget(previewNode, previewWidget, [host])
|
||||
promoteWidget(previewNode, previewWidget, [host])
|
||||
|
||||
expect(
|
||||
usePreviewExposureStore().getExposures(host.rootGraph.id, String(host.id))
|
||||
).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('demotes preview exposures when no linked value promotion exists', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const previewNode = new LGraphNode('PreviewImage')
|
||||
previewNode.type = 'PreviewImage'
|
||||
subgraph.add(previewNode)
|
||||
const previewWidget = widget({
|
||||
name: CANVAS_IMAGE_PREVIEW_WIDGET,
|
||||
serialize: false,
|
||||
type: 'preview'
|
||||
})
|
||||
promoteWidget(previewNode, previewWidget, [host])
|
||||
|
||||
demoteWidget(previewNode, previewWidget, [host])
|
||||
|
||||
expect(
|
||||
usePreviewExposureStore().getExposures(host.rootGraph.id, String(host.id))
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('leaves unexposed preview widgets unchanged when demoted', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const previewNode = new LGraphNode('PreviewImage')
|
||||
previewNode.type = 'PreviewImage'
|
||||
subgraph.add(previewNode)
|
||||
const previewWidget = widget({
|
||||
name: CANVAS_IMAGE_PREVIEW_WIDGET,
|
||||
serialize: false,
|
||||
type: 'preview'
|
||||
})
|
||||
|
||||
demoteWidget(previewNode, previewWidget, [host])
|
||||
|
||||
expect(
|
||||
usePreviewExposureStore().getExposures(host.rootGraph.id, String(host.id))
|
||||
).toEqual([])
|
||||
expect(addBreadcrumbMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining(CANVAS_IMAGE_PREVIEW_WIDGET)
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('promoteRecommendedWidgets', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
@@ -646,49 +346,6 @@ describe('promoteRecommendedWidgets', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps value promotion idempotent when the widget is already linked', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const interiorNode = new LGraphNode('Prompt')
|
||||
const input = interiorNode.addInput('text', 'STRING')
|
||||
const textWidget = interiorNode.addWidget('text', 'text', '', () => {})
|
||||
input.widget = { name: textWidget.name }
|
||||
subgraph.add(interiorNode)
|
||||
|
||||
expect(
|
||||
promoteValueWidgetViaSubgraphInput(subgraphNode, interiorNode, textWidget)
|
||||
.ok
|
||||
).toBe(true)
|
||||
expect(
|
||||
promoteValueWidgetViaSubgraphInput(subgraphNode, interiorNode, textWidget)
|
||||
.ok
|
||||
).toBe(true)
|
||||
|
||||
expect(subgraph.inputs.map((slot) => slot.name)).toEqual(['text'])
|
||||
})
|
||||
|
||||
it('seeds outer promoted widget state from a nested promoted input', () => {
|
||||
const { host: innerHost } = buildDuplicateNamePromotion()
|
||||
writePromotedInputValue(innerHost, 'text', 'inner value')
|
||||
const outerSubgraph = createTestSubgraph()
|
||||
const outerHost = createTestSubgraphNode(outerSubgraph)
|
||||
outerSubgraph.add(innerHost)
|
||||
|
||||
expect(
|
||||
promoteValueWidgetViaSubgraphInput(
|
||||
outerHost,
|
||||
innerHost,
|
||||
promotedWidgetRef(innerHost, 'text')
|
||||
).ok
|
||||
).toBe(true)
|
||||
|
||||
const hostInput = outerHost.inputs.find((input) => input.name === 'text')
|
||||
if (!hostInput?.widgetId) throw new Error('Missing promoted host widget id')
|
||||
expect(useWidgetValueStore().getWidget(hostInput.widgetId)?.value).toBe(
|
||||
'inner value'
|
||||
)
|
||||
})
|
||||
|
||||
it('promotes virtual previews through preview exposures', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
@@ -757,24 +414,6 @@ describe('promoteRecommendedWidgets', () => {
|
||||
})
|
||||
expect(updatePreviewsMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('records a breadcrumb when a recommended value widget has no source slot', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const interiorNode = new LGraphNode('CLIPTextEncode')
|
||||
interiorNode.type = 'CLIPTextEncode'
|
||||
interiorNode.addWidget('text', 'text', '', () => {})
|
||||
subgraph.add(interiorNode)
|
||||
|
||||
promoteRecommendedWidgets(subgraphNode)
|
||||
|
||||
expect(addBreadcrumbMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
level: 'warning',
|
||||
message: expect.stringContaining('missingSourceSlot')
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('autoExposeKnownPreviewNodes', () => {
|
||||
@@ -843,52 +482,6 @@ describe('autoExposeKnownPreviewNodes', () => {
|
||||
.map((e) => e.sourceNodeId)
|
||||
).not.toContain(String(glslNode.id))
|
||||
})
|
||||
|
||||
it('defers preview discovery for nodes without eager preview widgets', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const interiorNode = new LGraphNode('DeferredPreview')
|
||||
const rafCallbacks: FrameRequestCallback[] = []
|
||||
const requestAnimationFrameSpy = vi
|
||||
.spyOn(window, 'requestAnimationFrame')
|
||||
.mockImplementation((callback) => {
|
||||
rafCallbacks.push(callback)
|
||||
return rafCallbacks.length
|
||||
})
|
||||
subgraph.add(interiorNode)
|
||||
|
||||
try {
|
||||
autoExposeKnownPreviewNodes(subgraphNode)
|
||||
rafCallbacks[0]?.(0)
|
||||
const updateCallback = updatePreviewsMock.mock.calls[0]?.[1]
|
||||
const previewWidget = interiorNode.addWidget(
|
||||
'preview' as Parameters<typeof interiorNode.addWidget>[0],
|
||||
'preview',
|
||||
'',
|
||||
() => {}
|
||||
)
|
||||
previewWidget.serialize = false
|
||||
previewWidget.type = 'preview'
|
||||
updateCallback?.()
|
||||
|
||||
expect(updatePreviewsMock).toHaveBeenCalledWith(
|
||||
interiorNode,
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(
|
||||
usePreviewExposureStore().getExposures(
|
||||
subgraphNode.rootGraph.id,
|
||||
String(subgraphNode.id)
|
||||
)
|
||||
).toContainEqual({
|
||||
name: 'preview',
|
||||
sourceNodeId: String(interiorNode.id),
|
||||
sourcePreviewName: 'preview'
|
||||
})
|
||||
} finally {
|
||||
requestAnimationFrameSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasUnpromotedWidgets', () => {
|
||||
@@ -1080,25 +673,6 @@ describe('reorderSubgraphInputsByName', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('leaves unordered names after explicitly ordered inputs', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [
|
||||
{ name: 'first', type: 'number' },
|
||||
{ name: 'second', type: 'number' },
|
||||
{ name: 'third', type: 'number' }
|
||||
]
|
||||
})
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
|
||||
reorderSubgraphInputsByName(host, ['second'])
|
||||
|
||||
expect(host.subgraph.inputs.map((input) => input.name)).toEqual([
|
||||
'second',
|
||||
'first',
|
||||
'third'
|
||||
])
|
||||
})
|
||||
|
||||
it('updates subgraph input link slot indices after reordering', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
@@ -1194,33 +768,6 @@ describe('reorderSubgraphInputsByWidgetOrder', () => {
|
||||
'first value'
|
||||
])
|
||||
})
|
||||
|
||||
it('appends promoted inputs that are absent from the widget order', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const firstNode = new LGraphNode('First')
|
||||
const secondNode = new LGraphNode('Second')
|
||||
subgraph.add(firstNode)
|
||||
subgraph.add(secondNode)
|
||||
|
||||
const firstInput = firstNode.addInput('first', 'STRING')
|
||||
const firstWidget = firstNode.addWidget('text', 'first', '', () => {})
|
||||
firstInput.widget = { name: firstWidget.name }
|
||||
const secondInput = secondNode.addInput('second', 'STRING')
|
||||
const secondWidget = secondNode.addWidget('text', 'second', '', () => {})
|
||||
secondInput.widget = { name: secondWidget.name }
|
||||
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
|
||||
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
|
||||
|
||||
reorderSubgraphInputsByWidgetOrder(host, [
|
||||
promotedWidgetRef(host, 'second')
|
||||
])
|
||||
|
||||
expect(host.subgraph.inputs.map((input) => input.name)).toEqual([
|
||||
'second',
|
||||
'first'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('demoteWidget — axiomatic projection retraction', () => {
|
||||
@@ -1251,23 +798,6 @@ describe('demoteWidget — axiomatic projection retraction', () => {
|
||||
return { host, interiorNode, interiorWidget }
|
||||
}
|
||||
|
||||
it('runs as a no-op for an unpromoted non-preview widget', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const interiorNode = new LGraphNode('TestNode')
|
||||
host.subgraph.add(interiorNode)
|
||||
const widget = interiorNode.addWidget('text', 'value', 'initial', () => {})
|
||||
|
||||
demoteWidget(interiorNode, widget, [host])
|
||||
|
||||
expect(host.subgraph.inputs).toEqual([])
|
||||
expect(addBreadcrumbMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining('Demoted widget "value"')
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('drops projection but keeps slot and external link when host slot is externally connected', () => {
|
||||
const { host, interiorNode, interiorWidget } = setupPromotedWidget()
|
||||
const hostInput = host.inputs[0]
|
||||
@@ -1413,54 +943,4 @@ describe('disambiguated nested promotion identity', () => {
|
||||
|
||||
expect(outerHost.subgraph.inputs).toHaveLength(beforeCount)
|
||||
})
|
||||
|
||||
it('promotes a widget whose source widget state is missing', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const interiorNode = new LGraphNode('Source')
|
||||
subgraph.add(interiorNode)
|
||||
const interiorInput = interiorNode.addInput('text', 'STRING')
|
||||
const interiorWidget = interiorNode.addWidget('text', 'text', '', () => {})
|
||||
interiorInput.widget = { name: interiorWidget.name }
|
||||
interiorInput.widgetId = 'missing-widget-state' as WidgetId
|
||||
|
||||
expect(
|
||||
promoteValueWidgetViaSubgraphInput(host, interiorNode, interiorWidget).ok
|
||||
).toBe(true)
|
||||
expect(host.subgraph.inputs.map((input) => input.name)).toEqual(['text'])
|
||||
})
|
||||
|
||||
it('keeps plain inputs after ordered promoted widgets', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'plain', type: 'STRING' }]
|
||||
})
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
|
||||
reorderSubgraphInputsByWidgetOrder(host, [
|
||||
{ widgetId: 'missing-widget-state' as WidgetId }
|
||||
])
|
||||
|
||||
expect(host.inputs.map((input) => input.name)).toEqual(['plain'])
|
||||
})
|
||||
|
||||
it('falls back to append order when promoted input links are stale', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const interiorNode = new LGraphNode('Source')
|
||||
subgraph.add(interiorNode)
|
||||
const interiorInput = interiorNode.addInput('text', 'STRING')
|
||||
const interiorWidget = interiorNode.addWidget('text', 'text', '', () => {})
|
||||
interiorInput.widget = { name: interiorWidget.name }
|
||||
|
||||
expect(
|
||||
promoteValueWidgetViaSubgraphInput(host, interiorNode, interiorWidget).ok
|
||||
).toBe(true)
|
||||
const promotedInput = host.subgraph.inputs[0]
|
||||
const linkId = promotedInput.linkIds[0]
|
||||
host.subgraph.links.delete(linkId)
|
||||
|
||||
reorderSubgraphInputsByWidgetOrder(host, [promotedWidgetRef(host, 'text')])
|
||||
|
||||
expect(host.inputs.map((input) => input.name)).toEqual(['text'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { resolveInputType } from './dynamicTypes'
|
||||
|
||||
describe('resolveInputType', () => {
|
||||
it('splits concrete comma-delimited input types', () => {
|
||||
expect(resolveInputType({ type: 'MODEL,CLIP' } as never)).toEqual([
|
||||
'MODEL',
|
||||
'CLIP'
|
||||
])
|
||||
})
|
||||
|
||||
it('resolves match-type templates from allowed types', () => {
|
||||
expect(
|
||||
resolveInputType({
|
||||
type: 'COMFY_MATCHTYPE_V3',
|
||||
template: {
|
||||
allowed_types: 'IMAGE,MASK',
|
||||
template_id: 'image'
|
||||
}
|
||||
} as never)
|
||||
).toEqual(['IMAGE', 'MASK'])
|
||||
})
|
||||
|
||||
it('returns an empty type list for invalid match-type templates', () => {
|
||||
expect(resolveInputType({ type: 'COMFY_MATCHTYPE_V3' } as never)).toEqual(
|
||||
[]
|
||||
)
|
||||
})
|
||||
|
||||
it('resolves autogrow templates from required and optional inputs', () => {
|
||||
expect(
|
||||
resolveInputType({
|
||||
type: 'COMFY_AUTOGROW_V3',
|
||||
template: {
|
||||
input: {
|
||||
required: {
|
||||
image: ['IMAGE', {}]
|
||||
},
|
||||
optional: {
|
||||
mask: ['MASK,IMAGE', {}]
|
||||
}
|
||||
}
|
||||
}
|
||||
} as never)
|
||||
).toEqual(['IMAGE', 'MASK', 'IMAGE'])
|
||||
})
|
||||
|
||||
it('returns an empty type list for invalid autogrow templates', () => {
|
||||
expect(resolveInputType({ type: 'COMFY_AUTOGROW_V3' } as never)).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,19 +1,13 @@
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
import { LGraph, LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { describe, expect, test, vi } from 'vitest'
|
||||
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
|
||||
import { app } from '@/scripts/app'
|
||||
import type { InputSpec } from '@/schemas/nodeDefSchema'
|
||||
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import type { HasInitialMinSize } from '@/services/litegraphService'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { toLinkId } from '@/types/linkId'
|
||||
import { applyDynamicInputs, dynamicWidgets } from './dynamicWidgets'
|
||||
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
setActivePinia(createTestingPinia())
|
||||
type DynamicInputs = ('INT' | 'STRING' | 'IMAGE' | DynamicInputs)[][]
|
||||
type TestAutogrowNode = LGraphNode & {
|
||||
comfyDynamic: { autogrow: Record<string, unknown> }
|
||||
@@ -21,13 +15,6 @@ type TestAutogrowNode = LGraphNode & {
|
||||
|
||||
const { addNodeInput } = useLitegraphService()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
fromAny<{ configuringGraphLevel: number }, unknown>(
|
||||
app
|
||||
).configuringGraphLevel = 0
|
||||
})
|
||||
|
||||
function nextTick() {
|
||||
return new Promise<void>((r) => requestAnimationFrame(() => r()))
|
||||
}
|
||||
@@ -69,23 +56,6 @@ function addAutogrow(node: LGraphNode, template: unknown) {
|
||||
})
|
||||
)
|
||||
}
|
||||
function addMatchType(
|
||||
node: LGraphNode,
|
||||
name: string,
|
||||
allowedTypes = '*',
|
||||
templateId = 'a'
|
||||
) {
|
||||
addNodeInput(
|
||||
node,
|
||||
transformInputSpecV1ToV2(
|
||||
[
|
||||
'COMFY_MATCHTYPE_V3',
|
||||
{ template: { allowed_types: allowedTypes, template_id: templateId } }
|
||||
],
|
||||
{ name, isOptional: false }
|
||||
)
|
||||
)
|
||||
}
|
||||
function connectInput(node: LGraphNode, inputIndex: number, graph: LGraph) {
|
||||
const node2 = testNode()
|
||||
node2.addOutput('out', '*')
|
||||
@@ -146,312 +116,7 @@ describe('Dynamic Combos', () => {
|
||||
node.widgets[0].value = '1'
|
||||
expect.soft(node.widgets[1].tooltip).toBe('1')
|
||||
})
|
||||
|
||||
test('throws for malformed dynamic combo specs before creating a widget', () => {
|
||||
const node = testNode()
|
||||
const comboApp = { widgets: { COMBO: vi.fn() } } as unknown as Parameters<
|
||||
typeof dynamicWidgets.COMFY_DYNAMICCOMBO_V3
|
||||
>[3]
|
||||
|
||||
expect(() =>
|
||||
dynamicWidgets.COMFY_DYNAMICCOMBO_V3(
|
||||
node,
|
||||
'bad',
|
||||
['COMFY_DYNAMICCOMBO_V3', {}] as InputSpec,
|
||||
comboApp
|
||||
)
|
||||
).toThrow('invalid DynamicCombo spec')
|
||||
expect(comboApp.widgets.COMBO).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('clears grouped widgets when selection becomes empty', () => {
|
||||
const node = testNode()
|
||||
addDynamicCombo(node, [['INT'], ['INT', 'STRING']])
|
||||
node.widgets[0].value = '1'
|
||||
const onRemove = vi.fn()
|
||||
node.widgets[1].onRemove = onRemove
|
||||
|
||||
node.widgets[0].value = undefined
|
||||
|
||||
expect(onRemove).toHaveBeenCalled()
|
||||
expect(node.widgets).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('deletes widget state when removing grouped dynamic widgets', () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
graph.add(node)
|
||||
addDynamicCombo(node, [['INT'], ['STRING']])
|
||||
const childWidget = node.widgets[1]
|
||||
const childWidgetId = childWidget.widgetId
|
||||
if (!childWidgetId) throw new Error('Missing child widget id')
|
||||
const deleteWidget = vi.mocked(useWidgetValueStore().deleteWidget)
|
||||
|
||||
node.widgets[0].value = undefined
|
||||
|
||||
expect(deleteWidget).toHaveBeenCalledWith(childWidgetId)
|
||||
})
|
||||
|
||||
test('preserves an existing dynamic input link when refreshing a selection', () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
const onConnectionsChange = vi.fn()
|
||||
node.onConnectionsChange = onConnectionsChange
|
||||
graph.add(node)
|
||||
addDynamicCombo(node, [['IMAGE'], ['STRING']])
|
||||
node.widgets[0].value = '0'
|
||||
|
||||
connectInput(node, 1, graph)
|
||||
const linkId = node.inputs[1].link
|
||||
expect(linkId).not.toBeNull()
|
||||
onConnectionsChange.mockClear()
|
||||
|
||||
node.widgets[0].value = '0'
|
||||
|
||||
expect(node.inputs[1].link).toBe(linkId)
|
||||
expect(graph.links[linkId!].target_slot).toBe(1)
|
||||
expect(onConnectionsChange).toHaveBeenCalledWith(
|
||||
LiteGraph.INPUT,
|
||||
1,
|
||||
true,
|
||||
graph.links[linkId!],
|
||||
node.inputs[1]
|
||||
)
|
||||
})
|
||||
|
||||
test('throws if the backing widgets array disappears during update', () => {
|
||||
const node = testNode()
|
||||
addDynamicCombo(node, [['INT'], ['STRING']])
|
||||
const controller = node.widgets[0]
|
||||
node.widgets = undefined as unknown as typeof node.widgets
|
||||
|
||||
expect(() => {
|
||||
controller.value = '1'
|
||||
}).toThrow('Not Reachable')
|
||||
})
|
||||
|
||||
test('throws when the dynamic controller widget is missing during update', () => {
|
||||
const node = testNode()
|
||||
addDynamicCombo(node, [['INT'], ['STRING']])
|
||||
const controller = node.widgets[0]
|
||||
node.widgets = node.widgets.slice(1)
|
||||
|
||||
expect(() => {
|
||||
controller.value = '1'
|
||||
}).toThrow("Dynamic widget doesn't exist on node")
|
||||
})
|
||||
|
||||
test('throws when input-only dynamic sockets have no insertion point', () => {
|
||||
const node = testNode()
|
||||
addDynamicCombo(node, [['INT'], ['IMAGE']])
|
||||
const controller = node.widgets[0]
|
||||
node.inputs = []
|
||||
|
||||
expect(() => {
|
||||
controller.value = '1'
|
||||
}).toThrow('Failed to find input socket for 0')
|
||||
})
|
||||
|
||||
test('updates dynamic inputs without requiring a graph', () => {
|
||||
const node = testNode()
|
||||
addDynamicCombo(node, [['INT'], ['IMAGE']])
|
||||
|
||||
node.widgets[0].value = '1'
|
||||
|
||||
expect(node.inputs[1].type).toBe('IMAGE')
|
||||
})
|
||||
|
||||
test('reads dynamic combo values from widget state when available', () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
graph.add(node)
|
||||
addDynamicCombo(node, [['INT'], ['STRING']])
|
||||
const controller = node.widgets[0]
|
||||
const controllerId = controller.widgetId
|
||||
if (!controllerId) throw new Error('Missing controller widget id')
|
||||
|
||||
controller.value = '1'
|
||||
useWidgetValueStore().setValue(controllerId, '0')
|
||||
|
||||
expect(controller.value).toBe('0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Dynamic input dispatch', () => {
|
||||
test('returns false for unknown dynamic input types', () => {
|
||||
const node = testNode()
|
||||
|
||||
expect(
|
||||
applyDynamicInputs(node, {
|
||||
name: 'plain',
|
||||
type: 'STRING',
|
||||
isOptional: false
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
test('returns true after applying a known dynamic input type', () => {
|
||||
const node = testNode()
|
||||
|
||||
expect(
|
||||
applyDynamicInputs(
|
||||
node,
|
||||
transformInputSpecV1ToV2(
|
||||
[
|
||||
'COMFY_AUTOGROW_V3',
|
||||
{ template: { input: { required: { image: ['IMAGE', {}] } } } }
|
||||
],
|
||||
{ name: 'grow', isOptional: false }
|
||||
)
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('throws when an autogrow input spec is malformed', () => {
|
||||
const node = testNode()
|
||||
const inputSpec = {
|
||||
name: 'bad',
|
||||
type: 'COMFY_AUTOGROW_V3'
|
||||
} as InputSpecV2
|
||||
|
||||
expect(() => addNodeInput(node, inputSpec)).toThrow('invalid Autogrow spec')
|
||||
})
|
||||
|
||||
test('ignores malformed match type specs', () => {
|
||||
const node = testNode()
|
||||
|
||||
expect(
|
||||
applyDynamicInputs(node, {
|
||||
name: 'bad',
|
||||
type: 'COMFY_MATCHTYPE_V3',
|
||||
isOptional: false
|
||||
})
|
||||
).toBe(true)
|
||||
expect(node.inputs).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('MatchType inputs', () => {
|
||||
function createMatchTypeNode(graph: LGraph, outputMatchTypes = ['a']) {
|
||||
const node = testNode()
|
||||
node.constructor.nodeData = {
|
||||
name: 'testnode',
|
||||
output_matchtypes: outputMatchTypes
|
||||
} as typeof node.constructor.nodeData
|
||||
node.addOutput('out', '*')
|
||||
graph.add(node)
|
||||
addMatchType(node, 'on_true')
|
||||
addMatchType(node, 'on_false')
|
||||
return node
|
||||
}
|
||||
|
||||
function createSourceNode(graph: LGraph, type: string) {
|
||||
const node = testNode()
|
||||
node.addOutput('out', type)
|
||||
graph.add(node)
|
||||
return node
|
||||
}
|
||||
|
||||
test('ignores match type notifications outside registered inputs', () => {
|
||||
const graph = new LGraph()
|
||||
const node = createMatchTypeNode(graph)
|
||||
node.addInput('plain', 'STRING')
|
||||
|
||||
node.onConnectionsChange?.(LiteGraph.OUTPUT, 0, true, null, node.inputs[0])
|
||||
node.onConnectionsChange?.(LiteGraph.INPUT, 2, true, null, node.inputs[2])
|
||||
|
||||
expect(node.outputs[0].type).toBe('*')
|
||||
})
|
||||
|
||||
test('uses wildcard types for stale match type links', () => {
|
||||
const graph = new LGraph()
|
||||
const node = createMatchTypeNode(graph)
|
||||
node.inputs[0].link = toLinkId(999)
|
||||
|
||||
node.onConnectionsChange?.(LiteGraph.INPUT, 1, false, null, node.inputs[1])
|
||||
|
||||
expect(node.outputs[0].type).toBe('*')
|
||||
})
|
||||
|
||||
test('leaves unmatched output groups unchanged', () => {
|
||||
const graph = new LGraph()
|
||||
const node = createMatchTypeNode(graph, ['other'])
|
||||
const source = createSourceNode(graph, 'IMAGE')
|
||||
|
||||
source.connect(0, node, 0)
|
||||
|
||||
expect(node.outputs[0].type).toBe('*')
|
||||
})
|
||||
|
||||
test('throws when match group input constraints cannot overlap', () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
const requestAnimationFrameSpy = vi
|
||||
.spyOn(window, 'requestAnimationFrame')
|
||||
.mockImplementation(() => 1)
|
||||
node.constructor.nodeData = {
|
||||
name: 'testnode',
|
||||
output_matchtypes: ['a']
|
||||
} as typeof node.constructor.nodeData
|
||||
node.addOutput('out', '*')
|
||||
graph.add(node)
|
||||
addMatchType(node, 'image', 'IMAGE')
|
||||
addMatchType(node, 'latent', 'LATENT')
|
||||
const source = createSourceNode(graph, 'IMAGE')
|
||||
|
||||
try {
|
||||
expect(() => source.connect(0, node, 0)).toThrow('invalid connection')
|
||||
} finally {
|
||||
requestAnimationFrameSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test('disconnects downstream links when a match type output narrows', () => {
|
||||
const graph = new LGraph()
|
||||
const node = createMatchTypeNode(graph)
|
||||
const downstream = testNode()
|
||||
downstream.addInput('latent', 'LATENT')
|
||||
downstream.onConnectionsChange = vi.fn()
|
||||
graph.add(downstream)
|
||||
node.connect(0, downstream, 0)
|
||||
const source = createSourceNode(graph, 'IMAGE')
|
||||
|
||||
source.connect(0, node, 0)
|
||||
|
||||
expect(downstream.inputs[0].link).toBeNull()
|
||||
expect(downstream.onConnectionsChange).toHaveBeenCalledWith(
|
||||
LiteGraph.INPUT,
|
||||
0,
|
||||
false,
|
||||
expect.anything(),
|
||||
downstream.inputs[0]
|
||||
)
|
||||
})
|
||||
|
||||
test('ignores deferred match type refresh after the input is removed', () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
const rafCallbacks: FrameRequestCallback[] = []
|
||||
const requestAnimationFrameSpy = vi
|
||||
.spyOn(window, 'requestAnimationFrame')
|
||||
.mockImplementation((callback) => {
|
||||
rafCallbacks.push(callback)
|
||||
return rafCallbacks.length
|
||||
})
|
||||
graph.add(node)
|
||||
|
||||
try {
|
||||
addMatchType(node, 'removed')
|
||||
node.inputs.pop()
|
||||
rafCallbacks[0]?.(0)
|
||||
|
||||
expect(node.inputs).toHaveLength(0)
|
||||
} finally {
|
||||
requestAnimationFrameSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Autogrow', () => {
|
||||
const inputsSpec = { required: { image: ['IMAGE', {}] } }
|
||||
test('Can name by prefix', () => {
|
||||
@@ -497,259 +162,6 @@ describe('Autogrow', () => {
|
||||
connectInput(node, 2, graph)
|
||||
expect(node.inputs.length).toBe(3)
|
||||
})
|
||||
|
||||
test('ignores autogrow notifications that cannot affect a known input group', () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
graph.add(node)
|
||||
addAutogrow(node, { min: 1, input: inputsSpec, prefix: 'test' })
|
||||
const inputCount = node.inputs.length
|
||||
const unknownInput = node.addInput('outside.0', 'IMAGE')
|
||||
|
||||
node.onConnectionsChange?.(LiteGraph.OUTPUT, 0, true, null, node.inputs[0])
|
||||
node.onConnectionsChange?.(
|
||||
LiteGraph.INPUT,
|
||||
99,
|
||||
true,
|
||||
null,
|
||||
fromAny<
|
||||
Parameters<NonNullable<typeof node.onConnectionsChange>>[4],
|
||||
unknown
|
||||
>(undefined)
|
||||
)
|
||||
node.onConnectionsChange?.(LiteGraph.INPUT, 2, true, null, unknownInput)
|
||||
|
||||
expect(node.inputs).toHaveLength(inputCount + 1)
|
||||
})
|
||||
|
||||
test('does not grow autogrow inputs when connection metadata is missing', () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
graph.add(node)
|
||||
addAutogrow(node, { min: 1, input: inputsSpec, prefix: 'test' })
|
||||
|
||||
node.onConnectionsChange?.(LiteGraph.INPUT, 1, true, null, node.inputs[1])
|
||||
|
||||
expect(node.inputs).toHaveLength(2)
|
||||
})
|
||||
|
||||
test('keeps minimum autogrow rows when disconnecting early ordinals', async () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
graph.add(node)
|
||||
addAutogrow(node, { min: 2, input: inputsSpec, prefix: 'test' })
|
||||
|
||||
node.onConnectionsChange?.(LiteGraph.INPUT, 0, false, null, node.inputs[0])
|
||||
await nextTick()
|
||||
|
||||
expect(node.inputs).toHaveLength(3)
|
||||
})
|
||||
|
||||
test('restores a configure-time autogrow widget shim', () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
graph.add(node)
|
||||
addAutogrow(node, { min: 1, input: inputsSpec, prefix: 'test' })
|
||||
node.inputs[1].widget = { name: node.inputs[1].name }
|
||||
fromAny<{ configuringGraphLevel: number }, unknown>(
|
||||
app
|
||||
).configuringGraphLevel = 1
|
||||
|
||||
connectInput(node, 1, graph)
|
||||
|
||||
expect(node.widgets.some((widget) => widget.name === '0.test1')).toBe(true)
|
||||
})
|
||||
|
||||
test('draws configure-time autogrow shim text from the input name', () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
graph.add(node)
|
||||
addAutogrow(node, { min: 1, input: inputsSpec, prefix: 'test' })
|
||||
node.inputs[1].widget = { name: node.inputs[1].name }
|
||||
fromAny<{ configuringGraphLevel: number }, unknown>(
|
||||
app
|
||||
).configuringGraphLevel = 1
|
||||
|
||||
connectInput(node, 1, graph)
|
||||
const shim = node.widgets.find((widget) => widget.name === '0.test1')
|
||||
if (!shim?.draw) throw new Error('Missing shim widget')
|
||||
node.inputs[1].label = undefined
|
||||
const ctx = fromAny<CanvasRenderingContext2D, unknown>({
|
||||
save: vi.fn(),
|
||||
fillText: vi.fn(),
|
||||
restore: vi.fn()
|
||||
})
|
||||
|
||||
shim.draw(ctx, node, 100, 10, 20)
|
||||
|
||||
expect(ctx.fillText).toHaveBeenCalledWith('0.test1', 20, 25)
|
||||
})
|
||||
|
||||
test('keeps an existing configure-time autogrow widget shim', () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
graph.add(node)
|
||||
addAutogrow(node, { min: 1, input: inputsSpec, prefix: 'test' })
|
||||
node.inputs[1].widget = { name: node.inputs[1].name }
|
||||
node.widgets.push({
|
||||
name: node.inputs[1].name,
|
||||
type: 'shim',
|
||||
y: 0,
|
||||
options: {},
|
||||
serialize: false,
|
||||
draw: vi.fn()
|
||||
})
|
||||
fromAny<{ configuringGraphLevel: number }, unknown>(
|
||||
app
|
||||
).configuringGraphLevel = 1
|
||||
|
||||
connectInput(node, 1, graph)
|
||||
|
||||
expect(
|
||||
node.widgets.filter((widget) => widget.name === '0.test1')
|
||||
).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('defers disconnect handling during an input swap', () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
const rafCallbacks: FrameRequestCallback[] = []
|
||||
const requestAnimationFrameSpy = vi
|
||||
.spyOn(window, 'requestAnimationFrame')
|
||||
.mockImplementation((callback) => {
|
||||
rafCallbacks.push(callback)
|
||||
return rafCallbacks.length
|
||||
})
|
||||
graph.add(node)
|
||||
addAutogrow(node, { min: 1, input: inputsSpec, prefix: 'test' })
|
||||
|
||||
try {
|
||||
connectInput(node, 0, graph)
|
||||
node.disconnectInput(0)
|
||||
|
||||
expect(node.inputs).toHaveLength(2)
|
||||
expect(rafCallbacks).toHaveLength(2)
|
||||
} finally {
|
||||
requestAnimationFrameSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test('stops cleanup for uneven multi-input autogrow groups', async () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined)
|
||||
graph.add(node)
|
||||
addAutogrow(node, {
|
||||
min: 1,
|
||||
input: { required: { image: ['IMAGE', {}], mask: ['MASK', {}] } }
|
||||
})
|
||||
node.inputs.pop()
|
||||
|
||||
try {
|
||||
node.onConnectionsChange?.(
|
||||
LiteGraph.INPUT,
|
||||
0,
|
||||
false,
|
||||
null,
|
||||
node.inputs[0]
|
||||
)
|
||||
await nextTick()
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Failed to group multi-input autogrow inputs'
|
||||
)
|
||||
} finally {
|
||||
consoleErrorSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test('keeps trailing autogrow row when disconnecting the last slot', async () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
graph.add(node)
|
||||
addAutogrow(node, { min: 1, input: inputsSpec, prefix: 'test' })
|
||||
|
||||
node.onConnectionsChange?.(LiteGraph.INPUT, 1, false, null, node.inputs[1])
|
||||
await nextTick()
|
||||
|
||||
expect(node.inputs.map((input) => input.name)).toEqual([
|
||||
'0.test0',
|
||||
'0.test1'
|
||||
])
|
||||
})
|
||||
|
||||
test('ignores named autogrow input names outside the configured list', async () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
graph.add(node)
|
||||
addAutogrow(node, { min: 1, input: inputsSpec, names: ['a', 'b'] })
|
||||
const unknownInput = node.addInput('0.c', 'IMAGE')
|
||||
|
||||
node.onConnectionsChange?.(
|
||||
LiteGraph.INPUT,
|
||||
node.inputs.length - 1,
|
||||
false,
|
||||
null,
|
||||
unknownInput
|
||||
)
|
||||
await nextTick()
|
||||
|
||||
expect(node.inputs.map((input) => input.name)).toEqual([
|
||||
'0.a',
|
||||
'0.b',
|
||||
'0.c'
|
||||
])
|
||||
})
|
||||
|
||||
test('ignores autogrow input names without numeric ordinals', async () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
graph.add(node)
|
||||
addAutogrow(node, { min: 1, input: inputsSpec, prefix: 'test' })
|
||||
const unknownInput = node.addInput('0.testx', 'IMAGE')
|
||||
|
||||
node.onConnectionsChange?.(
|
||||
LiteGraph.INPUT,
|
||||
node.inputs.length - 1,
|
||||
false,
|
||||
null,
|
||||
unknownInput
|
||||
)
|
||||
await nextTick()
|
||||
|
||||
expect(node.inputs.map((input) => input.name)).toEqual([
|
||||
'0.test0',
|
||||
'0.test1',
|
||||
'0.testx'
|
||||
])
|
||||
})
|
||||
|
||||
test('marks optional autogrow inputs as optional after required inputs', () => {
|
||||
const node = testNode()
|
||||
|
||||
addAutogrow(node, {
|
||||
min: 1,
|
||||
input: {
|
||||
required: { image: ['IMAGE', {}] },
|
||||
optional: { mask: ['MASK', {}] }
|
||||
}
|
||||
})
|
||||
|
||||
expect(node.inputs.map((input) => input.name)).toEqual([
|
||||
'0.image0',
|
||||
'0.mask0',
|
||||
'0.image1',
|
||||
'0.mask1'
|
||||
])
|
||||
expect(node.inputs.map((input) => input.type)).toEqual([
|
||||
'IMAGE',
|
||||
'MASK',
|
||||
'IMAGE',
|
||||
'MASK'
|
||||
])
|
||||
})
|
||||
test('Removing connections decreases to min + 1', async () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
@@ -846,42 +258,6 @@ describe('Autogrow', () => {
|
||||
expect(vid0Link).not.toBeNull()
|
||||
expect(graph.links[vid0Link!].target_slot).toBe(vid0Index)
|
||||
})
|
||||
|
||||
test('removes shim widgets when multi-input autogrow rows shrink', async () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
graph.add(node)
|
||||
addAutogrow(node, {
|
||||
min: 1,
|
||||
input: { required: { image: ['IMAGE', {}], mask: ['MASK', {}] } }
|
||||
})
|
||||
connectInput(node, 2, graph)
|
||||
await nextTick()
|
||||
expect(node.inputs).toHaveLength(6)
|
||||
|
||||
const removedWidgetNames = ['0.image2', '0.mask2']
|
||||
const onRemove = vi.fn()
|
||||
for (const widget of node.widgets.filter((widget) =>
|
||||
removedWidgetNames.includes(widget.name)
|
||||
)) {
|
||||
widget.onRemove = onRemove
|
||||
}
|
||||
|
||||
node.disconnectInput(2)
|
||||
await nextTick()
|
||||
|
||||
expect(node.inputs.map((input) => input.name)).toEqual([
|
||||
'0.image0',
|
||||
'0.mask0',
|
||||
'0.image1',
|
||||
'0.mask1'
|
||||
])
|
||||
expect(onRemove).toHaveBeenCalledTimes(2)
|
||||
expect(
|
||||
node.widgets.some((widget) => removedWidgetNames.includes(widget.name))
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
test('Can deserialize a complex node', async () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
|
||||
@@ -127,45 +127,4 @@ describe('MatchType during configure', () => {
|
||||
expect(switchNode.inputs[1].link).not.toBeNull()
|
||||
expect(switchNode.outputs[0].type).toBe('IMAGE')
|
||||
})
|
||||
|
||||
test('keeps compatible downstream links after output type recalculation', () => {
|
||||
const graph = new LGraph()
|
||||
const switchNode = createMatchTypeNode(graph)
|
||||
const target = new LGraphNode('target')
|
||||
target.addInput('image', 'IMAGE')
|
||||
target.onConnectionsChange = vi.fn()
|
||||
graph.add(target)
|
||||
const source = createSourceNode(graph, 'IMAGE')
|
||||
|
||||
switchNode.connect(0, target, 0)
|
||||
vi.mocked(target.onConnectionsChange).mockClear()
|
||||
source.connect(0, switchNode, 0)
|
||||
|
||||
expect(switchNode.outputs[0].type).toBe('IMAGE')
|
||||
expect(target.inputs[0].link).not.toBeNull()
|
||||
expect(target.onConnectionsChange).toHaveBeenCalledWith(
|
||||
LiteGraph.INPUT,
|
||||
0,
|
||||
true,
|
||||
expect.anything(),
|
||||
target.inputs[0]
|
||||
)
|
||||
})
|
||||
|
||||
test('disconnects incompatible downstream links after output type recalculation', () => {
|
||||
const graph = new LGraph()
|
||||
const switchNode = createMatchTypeNode(graph)
|
||||
const target = new LGraphNode('target')
|
||||
target.addInput('image', 'IMAGE')
|
||||
graph.add(target)
|
||||
const source = createSourceNode(graph, 'LATENT')
|
||||
|
||||
switchNode.connect(0, target, 0)
|
||||
expect(target.inputs[0].link).not.toBeNull()
|
||||
|
||||
source.connect(0, switchNode, 0)
|
||||
|
||||
expect(switchNode.outputs[0].type).toBe('LATENT')
|
||||
expect(target.inputs[0].link).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,46 +1,14 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { SerialisedLLinkArray } from '@/lib/litegraph/src/LLink'
|
||||
import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { ComfyNode } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
import type { ComfyApp } from '@/scripts/app'
|
||||
import type { ComfyExtension } from '@/types/comfy'
|
||||
|
||||
import type { GroupNodeWorkflowData } from './groupNode'
|
||||
|
||||
const appMock = vi.hoisted(() => ({
|
||||
canvas: {
|
||||
emitAfterChange: vi.fn(),
|
||||
emitBeforeChange: vi.fn(),
|
||||
selected_nodes: {}
|
||||
},
|
||||
registerExtension: vi.fn(),
|
||||
registerNodeDef: vi.fn(),
|
||||
rootGraph: {
|
||||
convertToSubgraph: vi.fn(),
|
||||
extra: {},
|
||||
getNodeById: vi.fn(),
|
||||
links: {},
|
||||
nodes: [],
|
||||
remove: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
const widgetStoreMock = vi.hoisted(() => ({
|
||||
inputIsWidget: vi.fn((spec: unknown[]) =>
|
||||
['BOOLEAN', 'COMBO', 'FLOAT', 'INT', 'STRING'].includes(String(spec[0]))
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: appMock
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/widgetStore', () => ({
|
||||
useWidgetStore: () => widgetStoreMock
|
||||
app: {
|
||||
registerExtension: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
import { GroupNodeConfig, replaceLegacySeparators } from './groupNode'
|
||||
@@ -58,46 +26,6 @@ function makeNode(type: string): ComfyNode {
|
||||
}
|
||||
}
|
||||
|
||||
function makeNodeDef(overrides: Partial<ComfyNodeDef> = {}): ComfyNodeDef {
|
||||
return {
|
||||
name: 'TestNode',
|
||||
display_name: 'Test Node',
|
||||
description: '',
|
||||
category: 'test',
|
||||
input: { required: {}, optional: {} },
|
||||
output: [],
|
||||
output_name: [],
|
||||
output_is_list: [],
|
||||
output_node: false,
|
||||
python_module: 'test',
|
||||
...overrides
|
||||
} as ComfyNodeDef
|
||||
}
|
||||
|
||||
function extension(): ComfyExtension {
|
||||
const groupExtension = appMock.registerExtension.mock.calls.find(
|
||||
([registered]) => registered.name === 'Comfy.GroupNode'
|
||||
)?.[0]
|
||||
if (!groupExtension) throw new Error('GroupNode extension was not registered')
|
||||
return groupExtension as ComfyExtension
|
||||
}
|
||||
|
||||
function addCustomNodeDefs(defs: Record<string, ComfyNodeDef>) {
|
||||
const groupExtension = extension()
|
||||
if (!groupExtension.addCustomNodeDefs) {
|
||||
throw new Error('GroupNode extension does not implement addCustomNodeDefs')
|
||||
}
|
||||
groupExtension.addCustomNodeDefs(defs, appMock as unknown as ComfyApp)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
appMock.registerNodeDef.mockReset()
|
||||
widgetStoreMock.inputIsWidget.mockClear()
|
||||
LiteGraph.registered_node_types = {}
|
||||
addCustomNodeDefs({})
|
||||
})
|
||||
|
||||
describe('replaceLegacySeparators', () => {
|
||||
it('rewrites the legacy "workflow/" prefix to "workflow>"', () => {
|
||||
const nodes = [makeNode('workflow/My Group')]
|
||||
@@ -176,390 +104,4 @@ describe('GroupNodeConfig.getLinks', () => {
|
||||
const config = configFrom([], [[0, 1, 'IMAGE']])
|
||||
expect(config.externalFrom[0][1]).toBe('IMAGE')
|
||||
})
|
||||
|
||||
it('ignores external links without a type and accumulates multiple slots', () => {
|
||||
const config = configFrom(
|
||||
[],
|
||||
[
|
||||
[0, 1, null as unknown as string],
|
||||
[0, 2, 'LATENT'],
|
||||
[0, 3, 'IMAGE']
|
||||
]
|
||||
)
|
||||
|
||||
expect(config.externalFrom[0]).toEqual({ 2: 'LATENT', 3: 'IMAGE' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('GroupNodeConfig.getNodeDef', () => {
|
||||
const imageNodeDef = makeNodeDef({
|
||||
name: 'ImageNode',
|
||||
input: {
|
||||
required: {
|
||||
image: ['IMAGE', {}],
|
||||
mode: [['fast', 'slow'], {}]
|
||||
},
|
||||
optional: {
|
||||
strength: ['FLOAT', { default: 1 }]
|
||||
}
|
||||
},
|
||||
output: ['IMAGE'],
|
||||
output_name: ['image'],
|
||||
output_is_list: [false]
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
addCustomNodeDefs({ ImageNode: imageNodeDef })
|
||||
})
|
||||
|
||||
it('returns registered definitions for normal node types', () => {
|
||||
const config = new GroupNodeConfig('group', {
|
||||
nodes: [{ index: 0, type: 'ImageNode' }],
|
||||
links: [],
|
||||
external: []
|
||||
})
|
||||
|
||||
expect(config.getNodeDef({ index: 0, type: 'ImageNode' })).toBe(
|
||||
imageNodeDef
|
||||
)
|
||||
})
|
||||
|
||||
it('returns undefined for nodes without an index or a known type', () => {
|
||||
const config = new GroupNodeConfig('group', {
|
||||
nodes: [{ type: 'UnknownNode' }],
|
||||
links: [],
|
||||
external: []
|
||||
})
|
||||
|
||||
expect(config.getNodeDef({ type: 'UnknownNode' })).toBeUndefined()
|
||||
})
|
||||
|
||||
it('skips unlinked primitive nodes', () => {
|
||||
const config = new GroupNodeConfig('group', {
|
||||
nodes: [{ index: 0, type: 'PrimitiveNode' }],
|
||||
links: [],
|
||||
external: []
|
||||
})
|
||||
|
||||
expect(
|
||||
config.getNodeDef({ index: 0, type: 'PrimitiveNode' })
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('derives primitive node type from the outgoing link type', () => {
|
||||
const config = new GroupNodeConfig('group', {
|
||||
nodes: [
|
||||
{ index: 0, type: 'PrimitiveNode' },
|
||||
{ index: 1, type: 'ImageNode' }
|
||||
],
|
||||
links: [[0, 0, 1, 0, 1, 'IMAGE'] as SerialisedLLinkArray],
|
||||
external: []
|
||||
})
|
||||
|
||||
expect(
|
||||
config.getNodeDef({ index: 0, type: 'PrimitiveNode' })
|
||||
).toMatchObject({
|
||||
input: { required: { value: ['IMAGE', {}] } },
|
||||
output: ['IMAGE']
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to null when primitive combo target spec is not primitive', () => {
|
||||
const config = new GroupNodeConfig('group', {
|
||||
nodes: [
|
||||
{
|
||||
index: 0,
|
||||
type: 'PrimitiveNode',
|
||||
outputs: [{ name: 'mode', widget: { name: 'mode' } }]
|
||||
},
|
||||
{ index: 1, type: 'ImageNode' }
|
||||
],
|
||||
links: [[0, 0, 1, 0, 1, 'COMBO'] as SerialisedLLinkArray],
|
||||
external: []
|
||||
})
|
||||
|
||||
expect(config.getNodeDef(config.nodeData.nodes[0])).toMatchObject({
|
||||
input: { required: { value: [null, {}] } },
|
||||
output: [null]
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null for reroutes used only inside the group', () => {
|
||||
const config = new GroupNodeConfig('group', {
|
||||
nodes: [
|
||||
{ index: 0, type: 'ImageNode' },
|
||||
{ index: 1, type: 'Reroute' },
|
||||
{ index: 2, type: 'ImageNode' }
|
||||
],
|
||||
links: [
|
||||
[0, 0, 1, 0, 1, 'IMAGE'],
|
||||
[1, 0, 2, 0, 2, 'IMAGE']
|
||||
] as SerialisedLLinkArray[],
|
||||
external: []
|
||||
})
|
||||
|
||||
expect(config.getNodeDef({ index: 1, type: 'Reroute' })).toBeNull()
|
||||
})
|
||||
|
||||
it('derives reroute type from outgoing target inputs', () => {
|
||||
const config = new GroupNodeConfig('group', {
|
||||
nodes: [
|
||||
{ index: 0, type: 'Reroute' },
|
||||
{
|
||||
index: 1,
|
||||
type: 'ImageNode',
|
||||
inputs: [{ name: 'image', type: 'IMAGE' }]
|
||||
}
|
||||
],
|
||||
links: [[0, 0, 1, 0, 1, 'IMAGE'] as SerialisedLLinkArray],
|
||||
external: [[0, 0, 'IMAGE']]
|
||||
})
|
||||
|
||||
expect(config.getNodeDef({ index: 0, type: 'Reroute' })).toMatchObject({
|
||||
input: { required: { IMAGE: ['IMAGE', { forceInput: true }] } },
|
||||
output: ['IMAGE']
|
||||
})
|
||||
})
|
||||
|
||||
it('derives reroute type from incoming output metadata', () => {
|
||||
const config = new GroupNodeConfig('group', {
|
||||
nodes: [
|
||||
{ index: 0, type: 'ImageNode', outputs: [{ type: 'LATENT' }] },
|
||||
{ index: 1, type: 'Reroute' }
|
||||
],
|
||||
links: [[0, 0, 1, 0, 1, 'LATENT'] as SerialisedLLinkArray],
|
||||
external: [[1, 0, 'LATENT']]
|
||||
})
|
||||
|
||||
expect(config.getNodeDef({ index: 1, type: 'Reroute' })).toMatchObject({
|
||||
input: { required: { LATENT: ['LATENT', { forceInput: true }] } },
|
||||
output: ['LATENT']
|
||||
})
|
||||
})
|
||||
|
||||
it('derives pipe reroute type from external metadata when links omit it', () => {
|
||||
const config = new GroupNodeConfig('group', {
|
||||
nodes: [{ index: 0, type: 'Reroute' }],
|
||||
links: [],
|
||||
external: [[0, 0, 'MASK']]
|
||||
})
|
||||
|
||||
expect(config.getNodeDef({ index: 0, type: 'Reroute' })).toMatchObject({
|
||||
input: { required: { MASK: ['MASK', { forceInput: true }] } },
|
||||
output: ['MASK']
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('GroupNodeConfig input and output mapping', () => {
|
||||
function configWithNode(node: GroupNodeWorkflowData['nodes'][number]) {
|
||||
const config = new GroupNodeConfig('group', {
|
||||
nodes: [node],
|
||||
links: [],
|
||||
external: [],
|
||||
config: {
|
||||
0: {
|
||||
input: {
|
||||
hidden: { visible: false },
|
||||
renamed: { name: 'Custom Name' }
|
||||
},
|
||||
output: {
|
||||
1: { name: 'Custom Output' },
|
||||
2: { visible: false }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
config.nodeDef = makeNodeDef({
|
||||
input: { required: {} },
|
||||
output: [],
|
||||
output_name: [],
|
||||
output_is_list: []
|
||||
})
|
||||
return config
|
||||
}
|
||||
|
||||
it('renames duplicate inputs and adds seed control metadata', () => {
|
||||
const config = configWithNode({
|
||||
index: 0,
|
||||
type: 'Sampler',
|
||||
title: 'Sampler A',
|
||||
inputs: [{ name: 'seed', label: 'Seed Label' }]
|
||||
})
|
||||
const seenInputs = { seed: 1, 'Sampler A seed': 1 }
|
||||
const result = config.getInputConfig(
|
||||
{ index: 0, type: 'Sampler', title: 'Sampler A' },
|
||||
'seed',
|
||||
seenInputs,
|
||||
['INT', {}]
|
||||
)
|
||||
|
||||
expect(result.name).toBe('Sampler A 1 seed')
|
||||
expect(result.config).toEqual([
|
||||
'INT',
|
||||
{ control_after_generate: 'Sampler A control_after_generate' }
|
||||
])
|
||||
})
|
||||
|
||||
it('maps image upload widget aliases through converted widget names', () => {
|
||||
const config = configWithNode({ index: 0, type: 'LoadImage' })
|
||||
config.oldToNewWidgetMap[0] = { customImage: 'Uploaded Image' }
|
||||
|
||||
expect(
|
||||
config.getInputConfig({ index: 0, type: 'LoadImage' }, 'renamed', {}, [
|
||||
'IMAGEUPLOAD',
|
||||
{ widget: 'customImage' }
|
||||
])
|
||||
).toMatchObject({
|
||||
name: 'Custom Name',
|
||||
config: ['IMAGEUPLOAD', { widget: 'Uploaded Image' }]
|
||||
})
|
||||
})
|
||||
|
||||
it('splits widget inputs, socket inputs, and converted widget slots', () => {
|
||||
const config = configWithNode({
|
||||
index: 0,
|
||||
type: 'MixedNode',
|
||||
inputs: [{ name: 'mode', widget: { name: 'mode' } }]
|
||||
})
|
||||
|
||||
const result = config.processWidgetInputs(
|
||||
{
|
||||
mode: ['COMBO', {}],
|
||||
image: ['IMAGE', {}]
|
||||
},
|
||||
{
|
||||
index: 0,
|
||||
type: 'MixedNode',
|
||||
inputs: [{ name: 'mode', widget: { name: 'mode' } }]
|
||||
},
|
||||
['mode', 'image'],
|
||||
{}
|
||||
)
|
||||
|
||||
expect(result.slots).toEqual(['image'])
|
||||
expect(result.converted.get(0)).toBe('mode')
|
||||
expect(config.oldToNewWidgetMap[0].mode).toBeNull()
|
||||
})
|
||||
|
||||
it('adds visible unlinked input slots and skips hidden configured inputs', () => {
|
||||
const config = configWithNode({
|
||||
index: 0,
|
||||
type: 'InputNode'
|
||||
})
|
||||
const inputMap: Record<number, number> = {}
|
||||
config.processInputSlots(
|
||||
{
|
||||
image: ['IMAGE', {}],
|
||||
hidden: ['LATENT', {}]
|
||||
},
|
||||
{ index: 0, type: 'InputNode' },
|
||||
['image', 'hidden'],
|
||||
{},
|
||||
inputMap,
|
||||
{}
|
||||
)
|
||||
|
||||
expect(config.nodeDef?.input?.required).toEqual({ image: ['IMAGE', {}] })
|
||||
expect(inputMap).toEqual({ 0: 0 })
|
||||
})
|
||||
|
||||
it('adds output metadata, hides linked/internal outputs, and dedupes labels', () => {
|
||||
const config = configWithNode({
|
||||
index: 0,
|
||||
type: 'OutputNode',
|
||||
title: 'Output A',
|
||||
outputs: [{ name: 'image', label: 'Rendered' }]
|
||||
})
|
||||
config.linksFrom[0] = {
|
||||
0: [[0, 0, 1, 0, 1, 'IMAGE'] as SerialisedLLinkArray]
|
||||
}
|
||||
config.processNodeOutputs(
|
||||
{ index: 0, type: 'OutputNode', title: 'Output A' },
|
||||
{ Rendered: 1 },
|
||||
{
|
||||
input: { required: {} },
|
||||
output: ['IMAGE', 'LATENT', 'MASK'],
|
||||
output_name: ['image', 'latent', 'mask'],
|
||||
output_is_list: [false, true, false]
|
||||
}
|
||||
)
|
||||
|
||||
expect(config.outputVisibility).toEqual([false, true, false])
|
||||
expect(config.nodeDef?.output).toEqual(['LATENT'])
|
||||
expect(config.nodeDef?.output_is_list).toEqual([true])
|
||||
expect(config.nodeDef?.output_name).toEqual(['Custom Output'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('GroupNodeConfig.registerFromWorkflow', () => {
|
||||
it('adds missing type actions and skips registration for incomplete groups', async () => {
|
||||
const groupNodes: Record<string, GroupNodeWorkflowData> = {
|
||||
Broken: {
|
||||
nodes: [{ index: 0, type: 'MissingNode' }],
|
||||
links: [],
|
||||
external: []
|
||||
}
|
||||
}
|
||||
const missingNodeTypes: Parameters<
|
||||
typeof GroupNodeConfig.registerFromWorkflow
|
||||
>[1] = []
|
||||
|
||||
await GroupNodeConfig.registerFromWorkflow(groupNodes, missingNodeTypes)
|
||||
|
||||
expect(appMock.registerNodeDef).not.toHaveBeenCalled()
|
||||
expect(missingNodeTypes).toHaveLength(2)
|
||||
expect(missingNodeTypes[0]).toMatchObject({
|
||||
type: 'MissingNode',
|
||||
hint: " (In group node 'workflow>Broken')"
|
||||
})
|
||||
|
||||
const action = missingNodeTypes[1]
|
||||
if (typeof action === 'string') {
|
||||
throw new Error('Expected an action entry for the broken group node')
|
||||
}
|
||||
const target = document.createElement('button')
|
||||
const { callback } = action.action as {
|
||||
callback: (event: MouseEvent) => void
|
||||
}
|
||||
const event = new MouseEvent('click')
|
||||
Object.defineProperty(event, 'target', { value: target })
|
||||
callback(event)
|
||||
expect(groupNodes.Broken).toBeUndefined()
|
||||
expect(target.textContent).toBe('Removed')
|
||||
expect(target.style.pointerEvents).toBe('none')
|
||||
})
|
||||
|
||||
it('registers complete group node types and stores their generated node defs', async () => {
|
||||
addCustomNodeDefs({
|
||||
ImageNode: makeNodeDef({
|
||||
name: 'ImageNode',
|
||||
input: { required: { image: ['IMAGE', {}] } },
|
||||
output: ['IMAGE'],
|
||||
output_name: ['image'],
|
||||
output_is_list: [false]
|
||||
})
|
||||
})
|
||||
LiteGraph.registered_node_types.ImageNode = class extends LGraphNode {}
|
||||
|
||||
await GroupNodeConfig.registerFromWorkflow(
|
||||
{
|
||||
Complete: {
|
||||
nodes: [{ index: 0, type: 'ImageNode' }],
|
||||
links: [],
|
||||
external: [[0, 0, 'IMAGE']]
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
expect(appMock.registerNodeDef).toHaveBeenCalledWith(
|
||||
'workflow>Complete',
|
||||
expect.objectContaining({
|
||||
category: 'group nodes>workflow',
|
||||
display_name: 'Complete',
|
||||
name: 'workflow>Complete'
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,89 +1,18 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type {
|
||||
INodeInputSlot,
|
||||
INodeOutputSlot
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
NodeInputSlot,
|
||||
NodeOutputSlot,
|
||||
inputAsSerialisable,
|
||||
outputAsSerialisable
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { SlotType } from '@/lib/litegraph/src/draw'
|
||||
import type {
|
||||
DefaultConnectionColors,
|
||||
ReadOnlyRect
|
||||
} from '@/lib/litegraph/src/interfaces'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import {
|
||||
LinkDirection,
|
||||
RenderShape
|
||||
} from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { toLinkId } from '@/types/linkId'
|
||||
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
|
||||
|
||||
const boundingRect: ReadOnlyRect = [0, 0, 10, 10]
|
||||
|
||||
type MockCanvasContext = CanvasRenderingContext2D & {
|
||||
arc: ReturnType<typeof vi.fn>
|
||||
beginPath: ReturnType<typeof vi.fn>
|
||||
clip: ReturnType<typeof vi.fn>
|
||||
closePath: ReturnType<typeof vi.fn>
|
||||
fill: ReturnType<typeof vi.fn>
|
||||
fillText: ReturnType<typeof vi.fn>
|
||||
lineTo: ReturnType<typeof vi.fn>
|
||||
moveTo: ReturnType<typeof vi.fn>
|
||||
rect: ReturnType<typeof vi.fn>
|
||||
restore: ReturnType<typeof vi.fn>
|
||||
save: ReturnType<typeof vi.fn>
|
||||
stroke: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
function createContext(): MockCanvasContext {
|
||||
return {
|
||||
fillStyle: '#initial-fill',
|
||||
strokeStyle: '#initial-stroke',
|
||||
lineWidth: 7,
|
||||
textAlign: 'start',
|
||||
arc: vi.fn(),
|
||||
beginPath: vi.fn(),
|
||||
clip: vi.fn(),
|
||||
closePath: vi.fn(),
|
||||
fill: vi.fn(),
|
||||
fillText: vi.fn(),
|
||||
lineTo: vi.fn(),
|
||||
moveTo: vi.fn(),
|
||||
rect: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
save: vi.fn(),
|
||||
stroke: vi.fn()
|
||||
} as unknown as MockCanvasContext
|
||||
}
|
||||
|
||||
function createColors(): DefaultConnectionColors {
|
||||
return {
|
||||
getConnectedColor: vi.fn((type) => `connected-${type}`),
|
||||
getDisconnectedColor: vi.fn((type) => `disconnected-${type}`)
|
||||
}
|
||||
}
|
||||
|
||||
function createNode(): LGraphNode {
|
||||
return {
|
||||
pos: [100, 200],
|
||||
_collapsed_width: 80
|
||||
} as LGraphNode
|
||||
}
|
||||
|
||||
describe('NodeSlot', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal(
|
||||
'Path2D',
|
||||
class {
|
||||
arc = vi.fn()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('inputAsSerialisable', () => {
|
||||
it('removes _data from serialized slot', () => {
|
||||
const slot: INodeOutputSlot = {
|
||||
@@ -145,328 +74,4 @@ describe('NodeSlot', () => {
|
||||
expect(serialized.widget).not.toHaveProperty('options')
|
||||
})
|
||||
})
|
||||
|
||||
describe('rendering', () => {
|
||||
it('draws an input label on the right and restores canvas styles', () => {
|
||||
const ctx = createContext()
|
||||
const slot = new NodeInputSlot(
|
||||
{
|
||||
name: 'input',
|
||||
label: 'Input label',
|
||||
type: 'FLOAT',
|
||||
link: null,
|
||||
boundingRect: [110, 210, 10, 10]
|
||||
},
|
||||
createNode()
|
||||
)
|
||||
|
||||
slot.draw(ctx, { colorContext: createColors(), highlight: true })
|
||||
|
||||
expect(ctx.arc).toHaveBeenCalledWith(15, 15, 5, 0, Math.PI * 2)
|
||||
expect(ctx.fillText).toHaveBeenCalledWith('Input label', 25, 20)
|
||||
expect(ctx.fillStyle).toBe('#initial-fill')
|
||||
expect(ctx.strokeStyle).toBe('#initial-stroke')
|
||||
expect(ctx.lineWidth).toBe(7)
|
||||
expect(ctx.textAlign).toBe('start')
|
||||
})
|
||||
|
||||
it('draws output labels on the left and strokes output slots', () => {
|
||||
const ctx = createContext()
|
||||
const slot = new NodeOutputSlot(
|
||||
{
|
||||
name: 'output',
|
||||
localized_name: 'Localized output',
|
||||
type: 'FLOAT',
|
||||
links: [toLinkId(1)],
|
||||
boundingRect: [110, 210, 10, 10]
|
||||
},
|
||||
createNode()
|
||||
)
|
||||
|
||||
slot.draw(ctx, { colorContext: createColors() })
|
||||
|
||||
expect(ctx.stroke).toHaveBeenCalled()
|
||||
expect(ctx.fillText).toHaveBeenCalledWith('Localized output', 5, 20)
|
||||
expect(ctx.textAlign).toBe('start')
|
||||
expect(ctx.strokeStyle).toBe('#initial-stroke')
|
||||
})
|
||||
|
||||
it('draws event, box, arrow, grid, and low-quality slot shapes', () => {
|
||||
const colorContext = createColors()
|
||||
const node = createNode()
|
||||
const eventCtx = createContext()
|
||||
const boxCtx = createContext()
|
||||
const arrowCtx = createContext()
|
||||
const gridCtx = createContext()
|
||||
const lowQualityCtx = createContext()
|
||||
|
||||
new NodeInputSlot(
|
||||
{
|
||||
name: 'event',
|
||||
type: SlotType.Event,
|
||||
link: null,
|
||||
boundingRect: [110, 210, 10, 10]
|
||||
},
|
||||
node
|
||||
).draw(eventCtx, { colorContext })
|
||||
new NodeInputSlot(
|
||||
{
|
||||
name: 'box',
|
||||
type: 'FLOAT',
|
||||
shape: RenderShape.BOX,
|
||||
link: null,
|
||||
boundingRect: [110, 210, 10, 10]
|
||||
},
|
||||
node
|
||||
).draw(boxCtx, { colorContext })
|
||||
new NodeOutputSlot(
|
||||
{
|
||||
name: 'arrow',
|
||||
type: 'FLOAT',
|
||||
shape: RenderShape.ARROW,
|
||||
links: null,
|
||||
boundingRect: [110, 210, 10, 10]
|
||||
},
|
||||
node
|
||||
).draw(arrowCtx, { colorContext })
|
||||
new NodeInputSlot(
|
||||
{
|
||||
name: 'grid',
|
||||
type: SlotType.Array,
|
||||
link: null,
|
||||
boundingRect: [110, 210, 10, 10]
|
||||
},
|
||||
node
|
||||
).draw(gridCtx, { colorContext })
|
||||
new NodeInputSlot(
|
||||
{
|
||||
name: 'low',
|
||||
type: 'FLOAT',
|
||||
link: null,
|
||||
boundingRect: [110, 210, 10, 10]
|
||||
},
|
||||
node
|
||||
).draw(lowQualityCtx, { colorContext, lowQuality: true })
|
||||
|
||||
expect(eventCtx.rect).toHaveBeenCalledWith(9.5, 10.5, 14, 10)
|
||||
expect(boxCtx.rect).toHaveBeenCalledWith(9.5, 10.5, 14, 10)
|
||||
expect(arrowCtx.moveTo).toHaveBeenCalledWith(23, 15.5)
|
||||
expect(gridCtx.rect).toHaveBeenCalledTimes(9)
|
||||
expect(lowQualityCtx.rect).toHaveBeenCalledWith(11, 11, 8, 8)
|
||||
expect(lowQualityCtx.fillText).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('draws hollow and multi-type slots', () => {
|
||||
const colorContext = createColors()
|
||||
const hollowCtx = createContext()
|
||||
const multiCtx = createContext()
|
||||
|
||||
new NodeInputSlot(
|
||||
{
|
||||
name: 'hollow',
|
||||
type: 'FLOAT',
|
||||
shape: RenderShape.HollowCircle,
|
||||
link: null,
|
||||
boundingRect: [110, 210, 10, 10]
|
||||
},
|
||||
createNode()
|
||||
).draw(hollowCtx, { colorContext, highlight: true })
|
||||
new NodeInputSlot(
|
||||
{
|
||||
name: 'multi',
|
||||
type: 'A,B,C,D,E',
|
||||
link: toLinkId(1),
|
||||
boundingRect: [110, 210, 10, 10]
|
||||
},
|
||||
createNode()
|
||||
).draw(multiCtx, { colorContext })
|
||||
|
||||
expect(hollowCtx.clip).toHaveBeenCalledWith(expect.any(Object), 'evenodd')
|
||||
expect(
|
||||
vi
|
||||
.mocked(colorContext.getConnectedColor)
|
||||
.mock.calls.some(([type]) => type === 'A')
|
||||
).toBe(true)
|
||||
expect(multiCtx.fill.mock.calls.length).toBeGreaterThan(1)
|
||||
expect(multiCtx.stroke).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hides widget input labels and draws error rings', () => {
|
||||
const ctx = createContext()
|
||||
const slot = new NodeInputSlot(
|
||||
{
|
||||
name: 'widget-input',
|
||||
label: 'Hidden label',
|
||||
type: 'FLOAT',
|
||||
link: null,
|
||||
widget: { name: 'widget' },
|
||||
hasErrors: true,
|
||||
boundingRect: [110, 210, 10, 10]
|
||||
},
|
||||
createNode()
|
||||
)
|
||||
|
||||
slot.draw(ctx, { colorContext: createColors() })
|
||||
|
||||
expect(ctx.fillText).not.toHaveBeenCalled()
|
||||
expect(ctx.arc).toHaveBeenCalledWith(15, 15, 12, 0, Math.PI * 2)
|
||||
expect(ctx.stroke).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('places directional labels above vertical slots', () => {
|
||||
const rightCtx = createContext()
|
||||
const leftCtx = createContext()
|
||||
const node = createNode()
|
||||
const input = new NodeInputSlot(
|
||||
{
|
||||
name: 'up',
|
||||
type: 'FLOAT',
|
||||
link: null,
|
||||
dir: LinkDirection.UP,
|
||||
boundingRect: [110, 210, 10, 10]
|
||||
},
|
||||
node
|
||||
)
|
||||
const output = new NodeOutputSlot(
|
||||
{
|
||||
name: 'down',
|
||||
type: 'FLOAT',
|
||||
links: null,
|
||||
dir: LinkDirection.DOWN,
|
||||
boundingRect: [110, 210, 10, 10]
|
||||
},
|
||||
node
|
||||
)
|
||||
|
||||
input.draw(rightCtx, { colorContext: createColors() })
|
||||
output.draw(leftCtx, { colorContext: createColors() })
|
||||
|
||||
expect(rightCtx.fillText).toHaveBeenCalledWith('up', 15, 5)
|
||||
expect(leftCtx.fillText).toHaveBeenCalledWith('down', 15, 7)
|
||||
})
|
||||
})
|
||||
|
||||
describe('collapsed rendering', () => {
|
||||
it('draws collapsed input and output arrows in their own directions', () => {
|
||||
const inputCtx = createContext()
|
||||
const outputCtx = createContext()
|
||||
|
||||
new NodeInputSlot(
|
||||
{
|
||||
name: 'input',
|
||||
type: 'FLOAT',
|
||||
shape: RenderShape.ARROW,
|
||||
link: null,
|
||||
boundingRect
|
||||
},
|
||||
createNode()
|
||||
).drawCollapsed(inputCtx)
|
||||
new NodeOutputSlot(
|
||||
{
|
||||
name: 'output',
|
||||
type: 'FLOAT',
|
||||
shape: RenderShape.ARROW,
|
||||
links: null,
|
||||
boundingRect
|
||||
},
|
||||
createNode()
|
||||
).drawCollapsed(outputCtx)
|
||||
|
||||
expect(inputCtx.moveTo).toHaveBeenCalledWith(8, -15)
|
||||
expect(inputCtx.lineTo).toHaveBeenCalledWith(-4, -19)
|
||||
expect(outputCtx.moveTo).toHaveBeenCalledWith(86, -15)
|
||||
expect(outputCtx.lineTo).toHaveBeenCalledWith(74, -19)
|
||||
})
|
||||
|
||||
it('draws collapsed event and circle slots', () => {
|
||||
const eventCtx = createContext()
|
||||
const circleCtx = createContext()
|
||||
|
||||
new NodeInputSlot(
|
||||
{
|
||||
name: 'event',
|
||||
type: SlotType.Event,
|
||||
link: null,
|
||||
boundingRect
|
||||
},
|
||||
createNode()
|
||||
).drawCollapsed(eventCtx)
|
||||
new NodeInputSlot(
|
||||
{
|
||||
name: 'circle',
|
||||
type: 'FLOAT',
|
||||
link: null,
|
||||
boundingRect
|
||||
},
|
||||
createNode()
|
||||
).drawCollapsed(circleCtx)
|
||||
|
||||
expect(eventCtx.rect).toHaveBeenCalledWith(-6.5, -19, 14, 8)
|
||||
expect(circleCtx.arc).toHaveBeenCalledWith(0, -15, 4, 0, Math.PI * 2)
|
||||
expect(circleCtx.fillStyle).toBe('#initial-fill')
|
||||
})
|
||||
})
|
||||
|
||||
describe('serialization and validation', () => {
|
||||
it('serializes slot fields without the node reference', () => {
|
||||
const slot = new NodeOutputSlot(
|
||||
{
|
||||
name: 'out',
|
||||
type: 'FLOAT',
|
||||
label: 'Output',
|
||||
color_on: '#fff',
|
||||
color_off: '#000',
|
||||
shape: RenderShape.BOX,
|
||||
dir: LinkDirection.RIGHT,
|
||||
localized_name: 'Localized',
|
||||
pos: [1, 2],
|
||||
links: [toLinkId(3)],
|
||||
slot_index: 4,
|
||||
boundingRect: [1, 2, 3, 4]
|
||||
},
|
||||
createNode()
|
||||
)
|
||||
|
||||
expect(slot.toJSON()).toEqual({
|
||||
name: 'out',
|
||||
type: 'FLOAT',
|
||||
label: 'Output',
|
||||
color_on: '#fff',
|
||||
color_off: '#000',
|
||||
shape: RenderShape.BOX,
|
||||
dir: LinkDirection.RIGHT,
|
||||
localized_name: 'Localized',
|
||||
pos: [1, 2],
|
||||
boundingRect: [1, 2, 3, 4],
|
||||
links: [toLinkId(3)],
|
||||
slot_index: 4
|
||||
})
|
||||
})
|
||||
|
||||
it('validates input and output targets by slot direction', () => {
|
||||
const input = new NodeInputSlot(
|
||||
{
|
||||
name: 'input',
|
||||
type: 'FLOAT',
|
||||
link: null,
|
||||
boundingRect
|
||||
},
|
||||
createNode()
|
||||
)
|
||||
const output = new NodeOutputSlot(
|
||||
{
|
||||
name: 'output',
|
||||
type: 'FLOAT',
|
||||
links: null,
|
||||
boundingRect
|
||||
},
|
||||
createNode()
|
||||
)
|
||||
|
||||
expect(input.isValidTarget(output)).toBe(true)
|
||||
expect(output.isValidTarget(input)).toBe(true)
|
||||
expect(input.isValidTarget(input)).toBe(false)
|
||||
expect(output.isValidTarget(output)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
ExecutableNodeDTO,
|
||||
LGraph,
|
||||
LGraphEventMode,
|
||||
LLink,
|
||||
LGraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { toLinkId } from '@/types/linkId'
|
||||
@@ -25,14 +24,6 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
describe('ExecutableNodeDTO Creation', () => {
|
||||
it('should throw when the node has no graph', () => {
|
||||
const node = new LGraphNode('Detached')
|
||||
|
||||
expect(() => new ExecutableNodeDTO(node, [], new Map(), undefined)).toThrow(
|
||||
'Attempted to access LGraph reference that was null or undefined.'
|
||||
)
|
||||
})
|
||||
|
||||
it('should create DTO from regular node', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('Test Node')
|
||||
@@ -216,74 +207,6 @@ describe('ExecutableNodeDTO Input Resolution', () => {
|
||||
const resolved = dto.resolveInput(0)
|
||||
expect(resolved).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should throw when resolving a repeated input path', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('Looped')
|
||||
node.id = toNodeId(8)
|
||||
node.title = 'Loop title'
|
||||
node.addInput('in', 'IMAGE')
|
||||
graph.add(node)
|
||||
const dto = new ExecutableNodeDTO(node, ['parent'], new Map(), undefined)
|
||||
|
||||
expect(() =>
|
||||
dto.resolveInput(0, new Set([`undefined:${node.id}[I]0`]))
|
||||
).toThrow('Circular reference detected while resolving input 0')
|
||||
})
|
||||
|
||||
it('should report repeated root inputs without title or path details', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('')
|
||||
node.id = toNodeId(8)
|
||||
node.title = ''
|
||||
node.addInput('in', 'IMAGE')
|
||||
graph.add(node)
|
||||
const dto = new ExecutableNodeDTO(node, [], new Map(), undefined)
|
||||
|
||||
expect(() =>
|
||||
dto.resolveInput(0, new Set([`undefined:${node.id}[I]0`]))
|
||||
).toThrow('Circular reference detected while resolving input 0 of node 8')
|
||||
})
|
||||
|
||||
it('should throw when an input points at a missing link', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('Target')
|
||||
node.addInput('in', 'IMAGE')
|
||||
node.inputs[0].link = toLinkId(99)
|
||||
graph.add(node)
|
||||
const dto = new ExecutableNodeDTO(node, [], new Map(), undefined)
|
||||
|
||||
expect(() => dto.resolveInput(0)).toThrow('No link found in parent graph')
|
||||
})
|
||||
|
||||
it('should throw when an input link points at a missing source node', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('Target')
|
||||
node.id = toNodeId(2)
|
||||
node.addInput('in', 'IMAGE')
|
||||
graph.add(node)
|
||||
const link = new LLink(toLinkId(1), 'IMAGE', '404', 0, '2', 0)
|
||||
graph.links.set(link.id, link)
|
||||
node.inputs[0].link = link.id
|
||||
const dto = new ExecutableNodeDTO(node, [], new Map(), undefined)
|
||||
|
||||
expect(() => dto.resolveInput(0)).toThrow('No input node found')
|
||||
})
|
||||
|
||||
it('should throw when an input source has no DTO', () => {
|
||||
const graph = new LGraph()
|
||||
const source = new LGraphNode('Source')
|
||||
source.addOutput('out', 'IMAGE')
|
||||
graph.add(source)
|
||||
const target = new LGraphNode('Target')
|
||||
target.addInput('in', 'IMAGE')
|
||||
graph.add(target)
|
||||
source.connect(0, target, 0)
|
||||
|
||||
const dto = new ExecutableNodeDTO(target, [], new Map(), undefined)
|
||||
|
||||
expect(() => dto.resolveInput(0)).toThrow('No output node DTO found')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ExecutableNodeDTO Output Resolution', () => {
|
||||
@@ -334,34 +257,6 @@ describe('ExecutableNodeDTO Output Resolution', () => {
|
||||
expect(resolved?.node).toBe(dto)
|
||||
expect(resolved?.origin_slot).toBe(0)
|
||||
})
|
||||
|
||||
it('should throw when resolving a repeated output path', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('Looped')
|
||||
node.id = toNodeId(9)
|
||||
node.title = 'Loop title'
|
||||
node.addOutput('out', 'IMAGE')
|
||||
graph.add(node)
|
||||
const dto = new ExecutableNodeDTO(node, ['parent'], new Map(), undefined)
|
||||
|
||||
expect(() =>
|
||||
dto.resolveOutput(0, 'IMAGE', new Set([`undefined:${node.id}[O]0`]))
|
||||
).toThrow('Circular reference detected while resolving output 0')
|
||||
})
|
||||
|
||||
it('should report repeated root outputs without title or path details', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('')
|
||||
node.id = toNodeId(9)
|
||||
node.title = ''
|
||||
node.addOutput('out', 'IMAGE')
|
||||
graph.add(node)
|
||||
const dto = new ExecutableNodeDTO(node, [], new Map(), undefined)
|
||||
|
||||
expect(() =>
|
||||
dto.resolveOutput(0, 'IMAGE', new Set([`undefined:${node.id}[O]0`]))
|
||||
).toThrow('Circular reference detected while resolving output 0 of node 9')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Muted node output resolution', () => {
|
||||
@@ -473,135 +368,6 @@ describe('Bypass node output resolution', () => {
|
||||
expect(resolved).toBeDefined()
|
||||
expect(resolved?.node).toBe(upstreamDto)
|
||||
})
|
||||
|
||||
it('should use the first input when bypassing an any-type output', () => {
|
||||
const graph = new LGraph()
|
||||
|
||||
const upstreamNode = new LGraphNode('Upstream')
|
||||
upstreamNode.addOutput('out', 'IMAGE')
|
||||
graph.add(upstreamNode)
|
||||
|
||||
const bypassedNode = new LGraphNode('Bypassed')
|
||||
bypassedNode.addInput('fallback', 'IMAGE')
|
||||
bypassedNode.addOutput('first', 'IMAGE')
|
||||
bypassedNode.addOutput('second', 'IMAGE')
|
||||
bypassedNode.mode = LGraphEventMode.BYPASS
|
||||
graph.add(bypassedNode)
|
||||
|
||||
upstreamNode.connect(0, bypassedNode, 0)
|
||||
|
||||
const nodeDtoMap = new Map()
|
||||
const upstreamDto = new ExecutableNodeDTO(
|
||||
upstreamNode,
|
||||
[],
|
||||
nodeDtoMap,
|
||||
undefined
|
||||
)
|
||||
nodeDtoMap.set(upstreamDto.id, upstreamDto)
|
||||
|
||||
const bypassedDto = new ExecutableNodeDTO(
|
||||
bypassedNode,
|
||||
[],
|
||||
nodeDtoMap,
|
||||
undefined
|
||||
)
|
||||
nodeDtoMap.set(bypassedDto.id, bypassedDto)
|
||||
|
||||
const resolved = bypassedDto.resolveOutput(1, '*', new Set())
|
||||
expect(resolved?.node).toBe(upstreamDto)
|
||||
})
|
||||
|
||||
it('should use the same slot when bypassing an empty-type output', () => {
|
||||
const graph = new LGraph()
|
||||
|
||||
const upstreamNode = new LGraphNode('Upstream')
|
||||
upstreamNode.addOutput('out', 'IMAGE')
|
||||
graph.add(upstreamNode)
|
||||
|
||||
const bypassedNode = new LGraphNode('Bypassed')
|
||||
bypassedNode.addInput('image', 'IMAGE')
|
||||
bypassedNode.addOutput('out', 'IMAGE')
|
||||
bypassedNode.mode = LGraphEventMode.BYPASS
|
||||
graph.add(bypassedNode)
|
||||
|
||||
upstreamNode.connect(0, bypassedNode, 0)
|
||||
|
||||
const nodeDtoMap = new Map()
|
||||
const upstreamDto = new ExecutableNodeDTO(
|
||||
upstreamNode,
|
||||
[],
|
||||
nodeDtoMap,
|
||||
undefined
|
||||
)
|
||||
nodeDtoMap.set(upstreamDto.id, upstreamDto)
|
||||
|
||||
const bypassedDto = new ExecutableNodeDTO(
|
||||
bypassedNode,
|
||||
[],
|
||||
nodeDtoMap,
|
||||
undefined
|
||||
)
|
||||
nodeDtoMap.set(bypassedDto.id, bypassedDto)
|
||||
|
||||
const resolved = bypassedDto.resolveOutput(0, '', new Set())
|
||||
expect(resolved?.node).toBe(upstreamDto)
|
||||
})
|
||||
|
||||
it('should use an exact matching input when bypassing different slot types', () => {
|
||||
const graph = new LGraph()
|
||||
|
||||
const upstreamNode = new LGraphNode('Upstream')
|
||||
upstreamNode.addOutput('out', 'IMAGE')
|
||||
graph.add(upstreamNode)
|
||||
|
||||
const bypassedNode = new LGraphNode('Bypassed')
|
||||
bypassedNode.addInput('string', 'STRING')
|
||||
bypassedNode.addInput('image', 'IMAGE')
|
||||
bypassedNode.addOutput('latent', 'LATENT')
|
||||
bypassedNode.mode = LGraphEventMode.BYPASS
|
||||
graph.add(bypassedNode)
|
||||
|
||||
upstreamNode.connect(0, bypassedNode, 1)
|
||||
|
||||
const nodeDtoMap = new Map()
|
||||
const upstreamDto = new ExecutableNodeDTO(
|
||||
upstreamNode,
|
||||
[],
|
||||
nodeDtoMap,
|
||||
undefined
|
||||
)
|
||||
nodeDtoMap.set(upstreamDto.id, upstreamDto)
|
||||
|
||||
const bypassedDto = new ExecutableNodeDTO(
|
||||
bypassedNode,
|
||||
[],
|
||||
nodeDtoMap,
|
||||
undefined
|
||||
)
|
||||
nodeDtoMap.set(bypassedDto.id, bypassedDto)
|
||||
|
||||
const resolved = bypassedDto.resolveOutput(0, 'IMAGE', new Set())
|
||||
expect(resolved?.node).toBe(upstreamDto)
|
||||
})
|
||||
|
||||
it('should return undefined when no bypass input matches', () => {
|
||||
const graph = new LGraph()
|
||||
const bypassedNode = new LGraphNode('Bypassed')
|
||||
bypassedNode.addInput('string', 'STRING')
|
||||
bypassedNode.addOutput('out', 'LATENT')
|
||||
bypassedNode.mode = LGraphEventMode.BYPASS
|
||||
graph.add(bypassedNode)
|
||||
const dto = new ExecutableNodeDTO(bypassedNode, [], new Map(), undefined)
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
const resolved = dto.resolveOutput(0, 'IMAGE', new Set())
|
||||
|
||||
expect(resolved).toBeUndefined()
|
||||
expect(console.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('No input types match'),
|
||||
dto
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ALWAYS mode node output resolution', () => {
|
||||
@@ -717,94 +483,6 @@ describe('Virtual node resolveVirtualOutput', () => {
|
||||
expect(resolved).toBeUndefined()
|
||||
expect(spy).toHaveBeenCalledWith(0)
|
||||
})
|
||||
|
||||
it('should resolve through a virtual input link', () => {
|
||||
const graph = new LGraph()
|
||||
|
||||
const sourceNode = new LGraphNode('Source')
|
||||
sourceNode.addOutput('out', 'IMAGE')
|
||||
graph.add(sourceNode)
|
||||
|
||||
const passthroughNode = new LGraphNode('Passthrough')
|
||||
passthroughNode.addInput('in', 'IMAGE')
|
||||
graph.add(passthroughNode)
|
||||
sourceNode.connect(0, passthroughNode, 0)
|
||||
|
||||
const virtualNode = new LGraphNode('Virtual Get')
|
||||
virtualNode.addOutput('out', 'IMAGE')
|
||||
virtualNode.isVirtualNode = true
|
||||
virtualNode.resolveVirtualOutput = () => undefined
|
||||
graph.add(virtualNode)
|
||||
vi.spyOn(virtualNode, 'getInputLink').mockReturnValue({
|
||||
target_slot: 0,
|
||||
resolve: () => ({ inputNode: passthroughNode })
|
||||
} as unknown as LLink)
|
||||
|
||||
const nodeDtoMap = new Map()
|
||||
const sourceDto = new ExecutableNodeDTO(
|
||||
sourceNode,
|
||||
[],
|
||||
nodeDtoMap,
|
||||
undefined
|
||||
)
|
||||
nodeDtoMap.set(sourceDto.id, sourceDto)
|
||||
const passthroughDto = new ExecutableNodeDTO(
|
||||
passthroughNode,
|
||||
[],
|
||||
nodeDtoMap,
|
||||
undefined
|
||||
)
|
||||
nodeDtoMap.set(passthroughDto.id, passthroughDto)
|
||||
const virtualDto = new ExecutableNodeDTO(
|
||||
virtualNode,
|
||||
[],
|
||||
nodeDtoMap,
|
||||
undefined
|
||||
)
|
||||
|
||||
const resolved = virtualDto.resolveOutput(0, 'IMAGE', new Set())
|
||||
expect(resolved?.node).toBe(sourceDto)
|
||||
})
|
||||
|
||||
it('should throw when a virtual input link has no parent node', () => {
|
||||
const graph = new LGraph()
|
||||
const virtualNode = new LGraphNode('Virtual Get')
|
||||
virtualNode.addOutput('out', 'IMAGE')
|
||||
virtualNode.isVirtualNode = true
|
||||
virtualNode.resolveVirtualOutput = () => undefined
|
||||
graph.add(virtualNode)
|
||||
vi.spyOn(virtualNode, 'getInputLink').mockReturnValue({
|
||||
target_slot: 0,
|
||||
resolve: () => ({ inputNode: undefined })
|
||||
} as unknown as LLink)
|
||||
|
||||
const dto = new ExecutableNodeDTO(virtualNode, [], new Map(), undefined)
|
||||
|
||||
expect(() => dto.resolveOutput(0, 'IMAGE', new Set())).toThrow(
|
||||
'Virtual node failed to resolve parent'
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw when a virtual input link parent has no DTO', () => {
|
||||
const graph = new LGraph()
|
||||
const sourceNode = new LGraphNode('Source')
|
||||
graph.add(sourceNode)
|
||||
const virtualNode = new LGraphNode('Virtual Get')
|
||||
virtualNode.addOutput('out', 'IMAGE')
|
||||
virtualNode.isVirtualNode = true
|
||||
virtualNode.resolveVirtualOutput = () => undefined
|
||||
graph.add(virtualNode)
|
||||
vi.spyOn(virtualNode, 'getInputLink').mockReturnValue({
|
||||
target_slot: 0,
|
||||
resolve: () => ({ inputNode: sourceNode })
|
||||
} as unknown as LLink)
|
||||
|
||||
const dto = new ExecutableNodeDTO(virtualNode, [], new Map(), undefined)
|
||||
|
||||
expect(() => dto.resolveOutput(0, 'IMAGE', new Set())).toThrow(
|
||||
'No input node DTO found'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ExecutableNodeDTO Properties', () => {
|
||||
@@ -910,23 +588,6 @@ describe('ExecutableNodeDTO Memory Efficiency', () => {
|
||||
})
|
||||
|
||||
describe('ExecutableNodeDTO Integration', () => {
|
||||
it('should delegate getInnerNodes for subgraph nodes', () => {
|
||||
const subgraph = createTestSubgraph({ nodeCount: 2 })
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const executableNodes = new Map()
|
||||
const dto = new ExecutableNodeDTO(
|
||||
subgraphNode,
|
||||
[],
|
||||
executableNodes,
|
||||
undefined
|
||||
)
|
||||
|
||||
const innerNodes = dto.getInnerNodes()
|
||||
|
||||
expect(innerNodes).toHaveLength(2)
|
||||
expect(innerNodes[0]).toBeInstanceOf(ExecutableNodeDTO)
|
||||
})
|
||||
|
||||
it('should work with SubgraphNode flattening', () => {
|
||||
const subgraph = createTestSubgraph({ nodeCount: 3 })
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
@@ -999,65 +660,6 @@ describe('ExecutableNodeDTO Integration', () => {
|
||||
expect(Number(dto.node.id)).toBe(55) // Original node ID preserved
|
||||
expect(Number(dto.subgraphNode?.id)).toBe(99) // Subgraph context
|
||||
})
|
||||
|
||||
it('should throw when a subgraph output slot is missing', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const dto = new ExecutableNodeDTO(subgraphNode, [], new Map(), undefined)
|
||||
|
||||
expect(() => dto.resolveOutput(0, 'IMAGE', new Set())).toThrow(
|
||||
'No output found for flattened id'
|
||||
)
|
||||
})
|
||||
|
||||
it('should return undefined when a subgraph output has no inner link', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
outputs: [{ name: 'out', type: 'IMAGE' }]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
vi.spyOn(subgraphNode, 'resolveSubgraphOutputLink').mockReturnValue(
|
||||
undefined
|
||||
)
|
||||
const dto = new ExecutableNodeDTO(subgraphNode, [], new Map(), undefined)
|
||||
|
||||
const resolved = dto.resolveOutput(0, 'IMAGE', new Set())
|
||||
|
||||
expect(resolved).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should throw when a subgraph output link has no inner node', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
outputs: [{ name: 'out', type: 'IMAGE' }]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
vi.spyOn(subgraphNode, 'resolveSubgraphOutputLink').mockReturnValue({
|
||||
outputNode: undefined,
|
||||
link: new LLink(toLinkId(1), 'IMAGE', '1', 0, '2', 0)
|
||||
} as never)
|
||||
const dto = new ExecutableNodeDTO(subgraphNode, [], new Map(), undefined)
|
||||
|
||||
expect(() => dto.resolveOutput(0, 'IMAGE', new Set())).toThrow(
|
||||
'No output node found'
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw when a subgraph output inner node has no DTO', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
outputs: [{ name: 'out', type: 'IMAGE' }],
|
||||
nodeCount: 1
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const innerNode = subgraph.nodes[0]
|
||||
vi.spyOn(subgraphNode, 'resolveSubgraphOutputLink').mockReturnValue({
|
||||
outputNode: innerNode,
|
||||
link: new LLink(toLinkId(1), 'IMAGE', String(innerNode.id), 0, '2', 0)
|
||||
} as never)
|
||||
const dto = new ExecutableNodeDTO(subgraphNode, [], new Map(), undefined)
|
||||
|
||||
expect(() => dto.resolveOutput(0, 'IMAGE', new Set())).toThrow(
|
||||
'No inner node DTO found'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ExecutableNodeDTO Scale Testing', () => {
|
||||
|
||||
@@ -1,277 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { Rectangle } from '@/lib/litegraph/src/infrastructure/Rectangle'
|
||||
import type { DefaultConnectionColors } from '@/lib/litegraph/src/interfaces'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { CanvasPointerEvent } from '@/lib/litegraph/src/litegraph'
|
||||
import { CanvasItem } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import type { Subgraph } from '@/lib/litegraph/src/subgraph/Subgraph'
|
||||
import type { SubgraphInput } from '@/lib/litegraph/src/subgraph/SubgraphInput'
|
||||
import { SubgraphIONodeBase } from '@/lib/litegraph/src/subgraph/SubgraphIONodeBase'
|
||||
import type { NodeId } from '@/types/nodeId'
|
||||
|
||||
type MenuConfig = {
|
||||
title?: string
|
||||
callback?: (item: { content: string; value: string }) => void
|
||||
}
|
||||
|
||||
const { contextMenus, MockContextMenu } = vi.hoisted(() => {
|
||||
const contextMenus: Array<{
|
||||
options: unknown[]
|
||||
config: MenuConfig
|
||||
}> = []
|
||||
|
||||
class MockContextMenu {
|
||||
constructor(options: unknown[], config: MenuConfig) {
|
||||
contextMenus.push({ options, config })
|
||||
}
|
||||
}
|
||||
|
||||
return { contextMenus, MockContextMenu }
|
||||
})
|
||||
|
||||
type TestSlot = SubgraphInput & {
|
||||
arrange: ReturnType<typeof vi.fn>
|
||||
disconnect: ReturnType<typeof vi.fn>
|
||||
draw: ReturnType<typeof vi.fn>
|
||||
measure: ReturnType<typeof vi.fn>
|
||||
onPointerMove: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
class TestIONode extends SubgraphIONodeBase<SubgraphInput> {
|
||||
readonly id = 'subgraph-io' as NodeId
|
||||
readonly emptySlot: SubgraphInput
|
||||
readonly slots: SubgraphInput[]
|
||||
readonly renameSlot = vi.fn()
|
||||
readonly removeSlot = vi.fn()
|
||||
|
||||
constructor(
|
||||
subgraph: Subgraph,
|
||||
slots: SubgraphInput[],
|
||||
emptySlot: SubgraphInput
|
||||
) {
|
||||
super(subgraph)
|
||||
this.slots = slots
|
||||
this.emptySlot = emptySlot
|
||||
}
|
||||
|
||||
get allSlots(): SubgraphInput[] {
|
||||
return [...this.slots, this.emptySlot]
|
||||
}
|
||||
|
||||
get slotAnchorX(): number {
|
||||
return this.pos[0] + this.size[0] - SubgraphIONodeBase.roundedRadius
|
||||
}
|
||||
|
||||
onPointerDown(): void {}
|
||||
|
||||
openMenu(slot: SubgraphInput, event: CanvasPointerEvent): void {
|
||||
this.showSlotContextMenu(slot, event)
|
||||
}
|
||||
|
||||
renameByDoubleClick(slot: SubgraphInput, event: CanvasPointerEvent): void {
|
||||
this.handleSlotDoubleClick(slot, event)
|
||||
}
|
||||
|
||||
drawProtected(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
colorContext: DefaultConnectionColors,
|
||||
fromSlot?: SubgraphInput,
|
||||
editorAlpha?: number
|
||||
): void {
|
||||
ctx.lineWidth = 99
|
||||
ctx.strokeStyle = 'red'
|
||||
ctx.fillStyle = 'blue'
|
||||
ctx.font = '20px serif'
|
||||
ctx.textBaseline = 'top'
|
||||
this.drawSlots(ctx, colorContext, fromSlot, editorAlpha)
|
||||
}
|
||||
}
|
||||
|
||||
function createSlot(
|
||||
name: string,
|
||||
rect: [number, number, number, number],
|
||||
links: number[] = []
|
||||
): TestSlot {
|
||||
const slot = {
|
||||
name,
|
||||
displayName: `${name} label`,
|
||||
linkIds: links,
|
||||
boundingRect: new Rectangle(...rect),
|
||||
isPointerOver: false,
|
||||
measure: vi.fn(() => [rect[2], rect[3]]),
|
||||
arrange: vi.fn((nextRect: [number, number, number, number]) => {
|
||||
slot.boundingRect.set(nextRect)
|
||||
}),
|
||||
onPointerMove: vi.fn((event: CanvasPointerEvent) => {
|
||||
slot.isPointerOver = slot.boundingRect.containsXy(
|
||||
event.canvasX,
|
||||
event.canvasY
|
||||
)
|
||||
}),
|
||||
disconnect: vi.fn(),
|
||||
draw: vi.fn()
|
||||
}
|
||||
return slot as unknown as TestSlot
|
||||
}
|
||||
|
||||
function createSubgraph() {
|
||||
const prompt = vi.fn(
|
||||
(_title: string, _value: string, callback: (value: string) => void) =>
|
||||
callback('renamed')
|
||||
)
|
||||
return {
|
||||
prompt,
|
||||
subgraph: {
|
||||
setDirtyCanvas: vi.fn(),
|
||||
canvasAction: vi.fn(
|
||||
(callback: (canvas: { prompt: typeof prompt }) => void) =>
|
||||
callback({ prompt })
|
||||
)
|
||||
} as unknown as Subgraph
|
||||
}
|
||||
}
|
||||
|
||||
function createNode() {
|
||||
const filled = createSlot('value', [20, 30, 80, 20], [1])
|
||||
const empty = createSlot('', [20, 60, 80, 20])
|
||||
const { subgraph, prompt } = createSubgraph()
|
||||
const node = new TestIONode(subgraph, [filled], empty)
|
||||
node.configure({
|
||||
id: 'subgraph-io',
|
||||
bounding: [10, 20, 100, 80],
|
||||
pinned: false
|
||||
})
|
||||
return { node, filled, empty, subgraph, prompt }
|
||||
}
|
||||
|
||||
function eventAt(x: number, y: number): CanvasPointerEvent {
|
||||
return { canvasX: x, canvasY: y } as CanvasPointerEvent
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
contextMenus.length = 0
|
||||
Object.assign(LiteGraph, { ContextMenu: MockContextMenu })
|
||||
})
|
||||
|
||||
describe('SubgraphIONodeBase', () => {
|
||||
it('moves, snaps, hit-tests, and serializes node bounds', () => {
|
||||
const { node } = createNode()
|
||||
|
||||
node.move(5, -10)
|
||||
|
||||
expect(Array.from(node.pos)).toEqual([15, 10])
|
||||
expect(node.containsPoint([20, 20])).toBe(true)
|
||||
expect(node.asSerialisable()).toEqual({
|
||||
id: 'subgraph-io',
|
||||
bounding: [15, 10, 100, 80],
|
||||
pinned: undefined
|
||||
})
|
||||
|
||||
node.pinned = true
|
||||
expect(node.snapToGrid(10)).toBe(false)
|
||||
expect(node.asSerialisable().pinned).toBe(true)
|
||||
})
|
||||
|
||||
it('tracks pointer entry, slot hover, and pointer leave', () => {
|
||||
const { node, filled } = createNode()
|
||||
|
||||
const overResult = node.onPointerMove(eventAt(25, 35))
|
||||
|
||||
expect(overResult & CanvasItem.SubgraphIoNode).toBeTruthy()
|
||||
expect(overResult & CanvasItem.SubgraphIoSlot).toBeTruthy()
|
||||
expect(node.isPointerOver).toBe(true)
|
||||
expect(filled.isPointerOver).toBe(true)
|
||||
|
||||
const outResult = node.onPointerMove(eventAt(500, 500))
|
||||
|
||||
expect(outResult).toBe(CanvasItem.Nothing)
|
||||
expect(node.isPointerOver).toBe(false)
|
||||
expect(filled.isPointerOver).toBe(false)
|
||||
})
|
||||
|
||||
it('finds slots, arranges them, and restores drawing context state', () => {
|
||||
const { node, filled } = createNode()
|
||||
const ctx = {
|
||||
lineWidth: 1,
|
||||
strokeStyle: 'black',
|
||||
fillStyle: 'white',
|
||||
font: '12px sans-serif',
|
||||
textBaseline: 'middle'
|
||||
} as CanvasRenderingContext2D
|
||||
|
||||
node.arrange()
|
||||
node.draw(ctx, {} as DefaultConnectionColors, filled)
|
||||
|
||||
expect(node.getSlotInPosition(100, 40)).toBe(filled)
|
||||
expect(node.getSlotInPosition(500, 500)).toBeUndefined()
|
||||
expect(filled.arrange).toHaveBeenCalled()
|
||||
expect(node.size[0]).toBeGreaterThanOrEqual(108)
|
||||
expect(ctx.lineWidth).toBe(1)
|
||||
expect(ctx.strokeStyle).toBe('black')
|
||||
expect(ctx.fillStyle).toBe('white')
|
||||
expect(ctx.font).toBe('12px sans-serif')
|
||||
expect(ctx.textBaseline).toBe('middle')
|
||||
expect(filled.draw).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ ctx, fromSlot: filled })
|
||||
)
|
||||
})
|
||||
|
||||
it('prompts for non-empty slot rename on double click', () => {
|
||||
const { node, filled, empty, prompt } = createNode()
|
||||
|
||||
node.renameByDoubleClick(empty, eventAt(0, 0))
|
||||
expect(prompt).not.toHaveBeenCalled()
|
||||
|
||||
node.renameByDoubleClick(filled, eventAt(20, 30))
|
||||
|
||||
expect(prompt).toHaveBeenCalledWith(
|
||||
'Slot name',
|
||||
'value label',
|
||||
expect.any(Function),
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(node.renameSlot).toHaveBeenCalledWith(filled, 'renamed')
|
||||
})
|
||||
|
||||
it('opens slot context menu actions for connected non-empty slots', () => {
|
||||
const { node, filled, subgraph } = createNode()
|
||||
|
||||
node.openMenu(filled, eventAt(20, 30))
|
||||
|
||||
expect(contextMenus).toHaveLength(1)
|
||||
expect(contextMenus[0].config.title).toBe('value')
|
||||
expect(contextMenus[0].options).toMatchObject([
|
||||
{ value: 'disconnect' },
|
||||
{ value: 'rename' },
|
||||
null,
|
||||
{ value: 'remove', className: 'danger' }
|
||||
])
|
||||
|
||||
contextMenus[0].config.callback?.({
|
||||
content: 'Disconnect Links',
|
||||
value: 'disconnect'
|
||||
})
|
||||
contextMenus[0].config.callback?.({
|
||||
content: 'Rename Slot',
|
||||
value: 'rename'
|
||||
})
|
||||
contextMenus[0].config.callback?.({
|
||||
content: 'Remove Slot',
|
||||
value: 'remove'
|
||||
})
|
||||
|
||||
expect(filled.disconnect).toHaveBeenCalled()
|
||||
expect(node.renameSlot).toHaveBeenCalledWith(filled, 'renamed')
|
||||
expect(node.removeSlot).toHaveBeenCalledWith(filled)
|
||||
expect(subgraph.setDirtyCanvas).toHaveBeenCalledWith(true, true)
|
||||
})
|
||||
|
||||
it('does not open a context menu for the empty slot', () => {
|
||||
const { node, empty } = createNode()
|
||||
|
||||
node.openMenu(empty, eventAt(20, 60))
|
||||
|
||||
expect(contextMenus).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -1,273 +0,0 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer'
|
||||
import { LLink } from '@/lib/litegraph/src/LLink'
|
||||
import type {
|
||||
DefaultConnectionColors,
|
||||
INodeInputSlot
|
||||
} from '@/lib/litegraph/src/interfaces'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LinkConnector } from '@/lib/litegraph/src/canvas/LinkConnector'
|
||||
import type { NodeLike } from '@/lib/litegraph/src/types/NodeLike'
|
||||
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
|
||||
import { toLinkId } from '@/types/linkId'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import { toRerouteId } from '@/types/rerouteId'
|
||||
|
||||
import { createTestSubgraph } from './__fixtures__/subgraphHelpers'
|
||||
|
||||
function eventAt(x: number, y: number, button = 0): CanvasPointerEvent {
|
||||
return { canvasX: x, canvasY: y, button } as CanvasPointerEvent
|
||||
}
|
||||
|
||||
function createCanvasContext() {
|
||||
return {
|
||||
getTransform: vi.fn(() => new DOMMatrix()),
|
||||
translate: vi.fn(),
|
||||
beginPath: vi.fn(),
|
||||
arc: vi.fn(),
|
||||
moveTo: vi.fn(),
|
||||
lineTo: vi.fn(),
|
||||
stroke: vi.fn(),
|
||||
setTransform: vi.fn(),
|
||||
rect: vi.fn(),
|
||||
fill: vi.fn(),
|
||||
fillText: vi.fn(),
|
||||
strokeStyle: '',
|
||||
lineWidth: 1,
|
||||
font: '',
|
||||
fillStyle: '',
|
||||
textBaseline: '',
|
||||
globalAlpha: 1
|
||||
} as unknown as CanvasRenderingContext2D
|
||||
}
|
||||
|
||||
describe('SubgraphInputNode', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('exposes input slots plus the empty slot and computes its anchor', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'image', type: 'IMAGE' }]
|
||||
})
|
||||
subgraph.inputNode.configure({
|
||||
id: subgraph.inputNode.id,
|
||||
bounding: [10, 20, 100, 80],
|
||||
pinned: false
|
||||
})
|
||||
|
||||
expect(subgraph.inputNode.slots).toBe(subgraph.inputs)
|
||||
expect(subgraph.inputNode.allSlots).toEqual([
|
||||
subgraph.inputs[0],
|
||||
subgraph.inputNode.emptySlot
|
||||
])
|
||||
expect(subgraph.inputNode.slotAnchorX).toBe(96)
|
||||
})
|
||||
|
||||
it('sets link connector drag callbacks for left-clicked slots', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'image', type: 'IMAGE' }]
|
||||
})
|
||||
const slot = subgraph.inputs[0]
|
||||
slot.boundingRect.updateTo([10, 20, 100, 30])
|
||||
const pointer = {} as CanvasPointer
|
||||
const linkConnector = {
|
||||
dragNewFromSubgraphInput: vi.fn(),
|
||||
dropLinks: vi.fn(),
|
||||
reset: vi.fn()
|
||||
} as unknown as LinkConnector
|
||||
|
||||
subgraph.inputNode.onPointerDown(eventAt(20, 25), pointer, linkConnector)
|
||||
|
||||
pointer.onDragStart?.(pointer)
|
||||
pointer.onDragEnd?.(eventAt(40, 45))
|
||||
pointer.finally?.()
|
||||
|
||||
expect(linkConnector.dragNewFromSubgraphInput).toHaveBeenCalledWith(
|
||||
subgraph,
|
||||
subgraph.inputNode,
|
||||
slot
|
||||
)
|
||||
expect(linkConnector.dropLinks).toHaveBeenCalledWith(
|
||||
subgraph,
|
||||
expect.objectContaining({ canvasX: 40 })
|
||||
)
|
||||
expect(linkConnector.reset).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('opens the slot context menu for right-clicked slots', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'image', type: 'IMAGE' }]
|
||||
})
|
||||
const slot = subgraph.inputs[0]
|
||||
slot.boundingRect.updateTo([10, 20, 100, 30])
|
||||
const menuSpy = vi.spyOn(
|
||||
subgraph.inputNode as unknown as {
|
||||
showSlotContextMenu(slot: unknown, event: unknown): void
|
||||
},
|
||||
'showSlotContextMenu'
|
||||
)
|
||||
|
||||
subgraph.inputNode.onPointerDown(
|
||||
eventAt(20, 25, 2),
|
||||
{} as CanvasPointer,
|
||||
{} as LinkConnector
|
||||
)
|
||||
subgraph.inputNode.onPointerDown(
|
||||
eventAt(500, 500, 2),
|
||||
{} as CanvasPointer,
|
||||
{} as LinkConnector
|
||||
)
|
||||
|
||||
expect(menuSpy).toHaveBeenCalledOnce()
|
||||
expect(menuSpy).toHaveBeenCalledWith(
|
||||
slot,
|
||||
expect.objectContaining({ button: 2 })
|
||||
)
|
||||
})
|
||||
|
||||
it('renames and removes input slots through the parent subgraph', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'image', type: 'IMAGE' }]
|
||||
})
|
||||
const slot = subgraph.inputs[0]
|
||||
const renameSpy = vi.spyOn(subgraph, 'renameInput')
|
||||
const removeSpy = vi.spyOn(subgraph, 'removeInput')
|
||||
|
||||
subgraph.inputNode.renameSlot(slot, 'preview')
|
||||
subgraph.inputNode.removeSlot(slot)
|
||||
|
||||
expect(renameSpy).toHaveBeenCalledWith(slot, 'preview')
|
||||
expect(removeSpy).toHaveBeenCalledWith(slot)
|
||||
})
|
||||
|
||||
it('delegates connection checks and input-type connections', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'image', type: 'IMAGE' }]
|
||||
})
|
||||
const slot = subgraph.inputs[0]
|
||||
const inputSlot = {
|
||||
index: 0,
|
||||
slot: { name: 'in', type: 'IMAGE' }
|
||||
} as unknown as { index: number; slot: INodeInputSlot }
|
||||
const targetNode = new LGraphNode('Target')
|
||||
targetNode.id = toNodeId(99)
|
||||
vi.spyOn(targetNode, 'findInputByType').mockReturnValue(inputSlot)
|
||||
const link = new LLink(toLinkId(1), 'IMAGE', toNodeId(1), 0, toNodeId(2), 0)
|
||||
const connectSpy = vi.spyOn(slot, 'connect').mockReturnValue(link)
|
||||
const inputNode = fromPartial<NodeLike>({
|
||||
canConnectTo: vi.fn(() => true)
|
||||
})
|
||||
|
||||
expect(
|
||||
subgraph.inputNode.canConnectTo(inputNode, inputSlot.slot, slot)
|
||||
).toBe(true)
|
||||
expect(
|
||||
subgraph.inputNode.connectByType(0, targetNode, 'IMAGE', {
|
||||
afterRerouteId: toRerouteId(7)
|
||||
})
|
||||
).toBe(link)
|
||||
expect(connectSpy).toHaveBeenCalledWith(
|
||||
inputSlot.slot,
|
||||
targetNode,
|
||||
toRerouteId(7)
|
||||
)
|
||||
|
||||
vi.mocked(targetNode.findInputByType).mockReturnValue(undefined)
|
||||
expect(
|
||||
subgraph.inputNode.connectByType(0, targetNode, 'LATENT')
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('finds input slots by name and the first free slot by type', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [
|
||||
{ name: 'used', type: 'IMAGE' },
|
||||
{ name: 'free', type: 'IMAGE' }
|
||||
]
|
||||
})
|
||||
subgraph.inputs[0].linkIds.push(toLinkId(1))
|
||||
|
||||
expect(subgraph.inputNode.findOutputSlot('free')).toBe(subgraph.inputs[1])
|
||||
expect(subgraph.inputNode.findOutputByType('IMAGE')).toBe(
|
||||
subgraph.inputs[0]
|
||||
)
|
||||
expect(subgraph.inputNode.findOutputByType('LATENT')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('disconnects node inputs and clears floating links', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'image', type: 'IMAGE' }]
|
||||
})
|
||||
const targetNode = new LGraphNode('Target')
|
||||
targetNode.id = toNodeId(99)
|
||||
const input = targetNode.addInput('image', 'IMAGE')
|
||||
const floatingLink = new LLink(
|
||||
toLinkId(9),
|
||||
'IMAGE',
|
||||
subgraph.inputNode.id,
|
||||
0,
|
||||
targetNode.id,
|
||||
0
|
||||
)
|
||||
input._floatingLinks = new Set([floatingLink])
|
||||
input.link = toLinkId(3)
|
||||
const removeFloatingLinkSpy = vi.spyOn(subgraph, 'removeFloatingLink')
|
||||
const setDirtyCanvasSpy = vi.spyOn(subgraph, 'setDirtyCanvas')
|
||||
|
||||
subgraph.inputNode._disconnectNodeInput(targetNode, input, undefined)
|
||||
|
||||
expect(removeFloatingLinkSpy).toHaveBeenCalledWith(floatingLink)
|
||||
expect(input.link).toBeNull()
|
||||
expect(setDirtyCanvasSpy).toHaveBeenCalledWith(false, true)
|
||||
})
|
||||
|
||||
it('draws the side rail and input slots', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'image', type: 'IMAGE' }]
|
||||
})
|
||||
subgraph.inputNode.configure({
|
||||
id: subgraph.inputNode.id,
|
||||
bounding: [10, 20, 100, 80],
|
||||
pinned: false
|
||||
})
|
||||
const ctx = createCanvasContext()
|
||||
const drawSlotsSpy = vi.spyOn(
|
||||
subgraph.inputNode as unknown as {
|
||||
drawSlots(
|
||||
ctx: unknown,
|
||||
colorContext: unknown,
|
||||
fromSlot: unknown,
|
||||
editorAlpha: unknown
|
||||
): void
|
||||
},
|
||||
'drawSlots'
|
||||
)
|
||||
|
||||
subgraph.inputNode.drawProtected(
|
||||
ctx,
|
||||
{
|
||||
getConnectedColor: vi.fn(() => '#fff'),
|
||||
getDisconnectedColor: vi.fn(() => '#000')
|
||||
} as unknown as DefaultConnectionColors,
|
||||
subgraph.inputs[0],
|
||||
0.5
|
||||
)
|
||||
|
||||
expect(ctx.translate).toHaveBeenCalledWith(10, 20)
|
||||
expect(ctx.beginPath).toHaveBeenCalled()
|
||||
expect(ctx.stroke).toHaveBeenCalled()
|
||||
expect(ctx.setTransform).toHaveBeenCalled()
|
||||
expect(drawSlotsSpy).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
getConnectedColor: expect.any(Function),
|
||||
getDisconnectedColor: expect.any(Function)
|
||||
}),
|
||||
subgraph.inputs[0],
|
||||
0.5
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,225 +0,0 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LLink } from '@/lib/litegraph/src/LLink'
|
||||
import type {
|
||||
INodeInputSlot,
|
||||
INodeOutputSlot
|
||||
} from '@/lib/litegraph/src/interfaces'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { toLinkId } from '@/types/linkId'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import { toRerouteId } from '@/types/rerouteId'
|
||||
|
||||
import { createTestSubgraph } from './__fixtures__/subgraphHelpers'
|
||||
|
||||
function createWidget(
|
||||
overrides: Partial<Pick<IBaseWidget, 'name' | 'type' | 'options'>> = {}
|
||||
): IBaseWidget {
|
||||
return {
|
||||
name: overrides.name ?? 'strength',
|
||||
type: overrides.type ?? 'FLOAT',
|
||||
options: {
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.1,
|
||||
step2: 0.01,
|
||||
precision: 2,
|
||||
...overrides.options
|
||||
}
|
||||
} as IBaseWidget
|
||||
}
|
||||
|
||||
describe('SubgraphInput', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('connects subgraph inputs to node inputs', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'image', type: 'IMAGE' }]
|
||||
})
|
||||
const targetNode = new LGraphNode('Target')
|
||||
targetNode.id = toNodeId(10)
|
||||
subgraph.add(targetNode)
|
||||
const input = targetNode.addInput('image', 'IMAGE')
|
||||
const afterChangeSpy = vi.spyOn(subgraph, 'afterChange')
|
||||
const triggerSpy = vi.spyOn(subgraph, 'trigger')
|
||||
const connectionSpy = vi.fn()
|
||||
targetNode.onConnectionsChange = connectionSpy
|
||||
|
||||
const link = subgraph.inputs[0].connect(input, targetNode, toRerouteId(5))
|
||||
|
||||
expect(link).toBeInstanceOf(LLink)
|
||||
expect(link?.origin_id).toBe(subgraph.inputNode.id)
|
||||
expect(link?.target_id).toBe(targetNode.id)
|
||||
expect(link?.parentId).toBe(toRerouteId(5))
|
||||
expect(subgraph.inputs[0].linkIds).toEqual([link?.id])
|
||||
expect(input.link).toBe(link?.id)
|
||||
expect(triggerSpy).toHaveBeenCalledWith('node:slot-links:changed', {
|
||||
nodeId: targetNode.id,
|
||||
slotType: NodeSlotType.INPUT,
|
||||
slotIndex: 0,
|
||||
connected: true,
|
||||
linkId: link?.id
|
||||
})
|
||||
expect(connectionSpy).toHaveBeenCalledWith(
|
||||
NodeSlotType.INPUT,
|
||||
0,
|
||||
true,
|
||||
link,
|
||||
input
|
||||
)
|
||||
expect(afterChangeSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not connect when the target node blocks the input', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'image', type: 'IMAGE' }]
|
||||
})
|
||||
const targetNode = new LGraphNode('Target')
|
||||
const input = targetNode.addInput('image', 'IMAGE')
|
||||
targetNode.onConnectInput = vi.fn(() => false)
|
||||
|
||||
expect(subgraph.inputs[0].connect(input, targetNode)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects widget inputs that do not match the promoted widget', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'strength', type: 'FLOAT' }]
|
||||
})
|
||||
const targetNode = new LGraphNode('Target')
|
||||
const input = targetNode.addInput('strength', 'FLOAT')
|
||||
const currentWidget = createWidget()
|
||||
const otherWidget = createWidget({ options: { min: 1 } })
|
||||
input.widget = { name: otherWidget.name }
|
||||
targetNode.widgets = [otherWidget]
|
||||
subgraph.inputs[0]._widget = currentWidget
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
expect(subgraph.inputs[0].connect(input, targetNode)).toBeUndefined()
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'Target input has invalid widget.',
|
||||
input,
|
||||
targetNode
|
||||
)
|
||||
})
|
||||
|
||||
it('tracks connected widgets and clears them on disconnect', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'strength', type: 'FLOAT' }]
|
||||
})
|
||||
const targetNode = new LGraphNode('Target')
|
||||
targetNode.id = toNodeId(10)
|
||||
subgraph.add(targetNode)
|
||||
const input = targetNode.addInput('strength', 'FLOAT')
|
||||
const widget = createWidget()
|
||||
input.widget = { name: widget.name }
|
||||
targetNode.widgets = [widget]
|
||||
const connectedSpy = vi.fn()
|
||||
const disconnectedSpy = vi.fn()
|
||||
subgraph.inputs[0].events.addEventListener('input-connected', connectedSpy)
|
||||
subgraph.inputs[0].events.addEventListener(
|
||||
'input-disconnected',
|
||||
disconnectedSpy
|
||||
)
|
||||
|
||||
const link = subgraph.inputs[0].connect(input, targetNode)
|
||||
|
||||
expect(subgraph.inputs[0]._widget).toBe(widget)
|
||||
expect(subgraph.inputs[0].getConnectedWidgets()).toEqual([widget])
|
||||
expect(connectedSpy).toHaveBeenCalledOnce()
|
||||
|
||||
subgraph.inputs[0].disconnect()
|
||||
|
||||
expect(subgraph.inputs[0]._widget).toBeUndefined()
|
||||
expect(subgraph.inputs[0].linkIds).toEqual([])
|
||||
expect(disconnectedSpy).toHaveBeenCalledTimes(2)
|
||||
expect(subgraph.getLink(link?.id ?? toLinkId(-1))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('arranges and labels from the right edge', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'image', type: 'IMAGE' }]
|
||||
})
|
||||
const input = subgraph.inputs[0]
|
||||
|
||||
input.arrange([140, 30, 120, 40])
|
||||
|
||||
expect(Array.from(input.boundingRect)).toEqual([20, 30, 120, 40])
|
||||
expect(input.pos).toEqual([120, 50])
|
||||
expect(input.labelPos).toEqual([20, 50])
|
||||
})
|
||||
|
||||
it('validates node inputs and subgraph outputs as targets', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'source', type: 'IMAGE' }],
|
||||
outputs: [{ name: 'preview', type: 'IMAGE' }]
|
||||
})
|
||||
const input = subgraph.inputs[0]
|
||||
const imageInput = { name: 'image', type: 'IMAGE', link: null }
|
||||
const latentInput = { name: 'latent', type: 'LATENT', link: null }
|
||||
const imageOutput = fromPartial<INodeOutputSlot>({
|
||||
name: 'image',
|
||||
type: 'IMAGE',
|
||||
links: []
|
||||
})
|
||||
|
||||
expect(input.isValidTarget(imageInput as INodeInputSlot)).toBe(true)
|
||||
expect(input.isValidTarget(latentInput as INodeInputSlot)).toBe(false)
|
||||
expect(input.isValidTarget(imageOutput)).toBe(false)
|
||||
expect(input.isValidTarget(subgraph.outputs[0])).toBe(true)
|
||||
})
|
||||
|
||||
it('matches widget options by type and numeric constraints', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'strength', type: 'FLOAT' }]
|
||||
})
|
||||
const input = subgraph.inputs[0]
|
||||
input._widget = createWidget()
|
||||
|
||||
expect(input.matchesWidget(createWidget())).toBe(true)
|
||||
expect(input.matchesWidget(createWidget({ type: 'INT' }))).toBe(false)
|
||||
expect(input.matchesWidget(createWidget({ options: { max: 2 } }))).toBe(
|
||||
false
|
||||
)
|
||||
|
||||
input._widget = undefined
|
||||
expect(input.matchesWidget(createWidget({ type: 'INT' }))).toBe(true)
|
||||
})
|
||||
|
||||
it('disconnects node inputs and removes link references', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'image', type: 'IMAGE' }]
|
||||
})
|
||||
const targetNode = new LGraphNode('Target')
|
||||
targetNode.id = toNodeId(10)
|
||||
subgraph.add(targetNode)
|
||||
const input = targetNode.addInput('image', 'IMAGE')
|
||||
const link = subgraph.inputs[0].connect(input, targetNode)
|
||||
const triggerSpy = vi.spyOn(subgraph, 'trigger')
|
||||
const connectionSpy = vi.fn()
|
||||
targetNode.onConnectionsChange = connectionSpy
|
||||
|
||||
subgraph.inputNode._disconnectNodeInput(targetNode, input, link)
|
||||
|
||||
expect(input.link).toBeNull()
|
||||
expect(subgraph.inputs[0].linkIds).toEqual([])
|
||||
expect(connectionSpy).toHaveBeenCalledWith(
|
||||
NodeSlotType.INPUT,
|
||||
0,
|
||||
false,
|
||||
link,
|
||||
subgraph.inputs[0]
|
||||
)
|
||||
expect(triggerSpy).toHaveBeenCalledWith('node:slot-links:changed', {
|
||||
nodeId: targetNode.id,
|
||||
slotType: NodeSlotType.INPUT,
|
||||
slotIndex: 0,
|
||||
connected: false,
|
||||
linkId: link?.id
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -17,13 +17,9 @@ import {
|
||||
SubgraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { ExportedSubgraphInstance } from '@/lib/litegraph/src/types/serialisation'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { createNodeLocatorId } from '@/types/nodeIdentification'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import type { WidgetId } from '@/types/widgetId'
|
||||
|
||||
import { subgraphTest } from './__fixtures__/subgraphFixtures'
|
||||
import {
|
||||
@@ -104,18 +100,6 @@ describe('SubgraphNode Construction', () => {
|
||||
expect(subgraphNode.widgets).toEqual([])
|
||||
})
|
||||
|
||||
it('warns when external code assigns widgets directly', () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
|
||||
subgraphNode.widgets = []
|
||||
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'Cannot manually set widgets on SubgraphNode; use the promotion system.'
|
||||
)
|
||||
})
|
||||
|
||||
subgraphTest(
|
||||
'should synchronize slots with subgraph definition',
|
||||
({ subgraphWithNode }) => {
|
||||
@@ -236,38 +220,6 @@ describe('SubgraphNode Synchronization', () => {
|
||||
expect(subgraphNode.outputs[0].label).toBe('newOutput')
|
||||
})
|
||||
|
||||
it('throws when input rename events reference a missing slot', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'input', type: 'number' }]
|
||||
})
|
||||
createTestSubgraphNode(subgraph)
|
||||
|
||||
expect(() =>
|
||||
subgraph.events.dispatch('renaming-input', {
|
||||
input: subgraph.inputs[0],
|
||||
index: 99,
|
||||
oldName: 'input',
|
||||
newName: 'missing'
|
||||
})
|
||||
).toThrow('Subgraph input not found')
|
||||
})
|
||||
|
||||
it('throws when output rename events reference a missing slot', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
outputs: [{ name: 'output', type: 'number' }]
|
||||
})
|
||||
createTestSubgraphNode(subgraph)
|
||||
|
||||
expect(() =>
|
||||
subgraph.events.dispatch('renaming-output', {
|
||||
output: subgraph.outputs[0],
|
||||
index: 99,
|
||||
oldName: 'output',
|
||||
newName: 'missing'
|
||||
})
|
||||
).toThrow('Subgraph output not found')
|
||||
})
|
||||
|
||||
it('represents promoted host widgets by input widgetId and WidgetState', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'text', type: 'STRING' }]
|
||||
@@ -410,41 +362,6 @@ describe('SubgraphNode Synchronization', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back projected widget fields when WidgetState is missing', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'text', type: 'STRING' }]
|
||||
})
|
||||
|
||||
const interiorNode = new LGraphNode('Interior')
|
||||
const input = interiorNode.addInput('value', 'STRING')
|
||||
input.widget = { name: 'value' }
|
||||
interiorNode.addOutput('out', 'STRING')
|
||||
interiorNode.addWidget('text', 'value', 'initial', () => {})
|
||||
subgraph.add(interiorNode)
|
||||
subgraph.inputNode.slots[0].connect(interiorNode.inputs[0], interiorNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const promotedInput = subgraphNode.inputs[0]
|
||||
const widget = subgraphNode.widgets[0]
|
||||
const id = promotedInput.widgetId
|
||||
if (!id) throw new Error('Missing widgetId')
|
||||
if (!widget) throw new Error('Missing projected widget')
|
||||
|
||||
useWidgetValueStore().deleteWidget(id)
|
||||
|
||||
expect(widget.name).toBe('text')
|
||||
expect(widget.label).toBe('text')
|
||||
expect(widget.y).toBe(0)
|
||||
expect(widget.type).toBe('text')
|
||||
expect(widget.options).toEqual({})
|
||||
expect(widget.value).toBeUndefined()
|
||||
expect(() => {
|
||||
widget.label = 'Label'
|
||||
widget.y = 12
|
||||
widget.callback?.('updated')
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('should keep input.widget.name stable after rename (onGraphConfigured safety)', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'text', type: 'STRING' }]
|
||||
@@ -526,111 +443,6 @@ describe('SubgraphNode Synchronization', () => {
|
||||
'My Seed'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps rename behavior when widget state has been removed', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'text', type: 'STRING' }]
|
||||
})
|
||||
|
||||
const interiorNode = new LGraphNode('Interior')
|
||||
const input = interiorNode.addInput('value', 'STRING')
|
||||
input.widget = { name: 'value' }
|
||||
interiorNode.addWidget('text', 'value', 'initial', () => {})
|
||||
subgraph.add(interiorNode)
|
||||
subgraph.inputNode.slots[0].connect(interiorNode.inputs[0], interiorNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const promotedInput = subgraphNode.inputs[0]
|
||||
const widgetId = promotedInput.widgetId
|
||||
if (!widgetId) throw new Error('Missing widgetId')
|
||||
useWidgetValueStore().deleteWidget(widgetId)
|
||||
|
||||
subgraph.renameInput(subgraph.inputs[0], 'Renamed Text')
|
||||
|
||||
expect(promotedInput.label).toBe('Renamed Text')
|
||||
expect(useWidgetValueStore().getWidget(widgetId)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rebinds promoted widgets when subgraph input objects are recreated', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'text', type: 'STRING' }]
|
||||
})
|
||||
|
||||
const interiorNode = new LGraphNode('Interior')
|
||||
interiorNode.id = toNodeId(5)
|
||||
const input = interiorNode.addInput('value', 'STRING')
|
||||
input.widget = { name: 'value' }
|
||||
interiorNode.addWidget('text', 'value', 'initial', () => {})
|
||||
subgraph.add(interiorNode)
|
||||
subgraph.inputNode.slots[0].connect(interiorNode.inputs[0], interiorNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const originalSlot = subgraphNode.inputs[0]._subgraphSlot
|
||||
const originalWidgetId = subgraphNode.inputs[0].widgetId
|
||||
const serialized = subgraph.asSerialisable()
|
||||
|
||||
subgraph.configure(serialized)
|
||||
|
||||
expect(subgraphNode.inputs).toHaveLength(1)
|
||||
expect(subgraphNode.inputs[0]._subgraphSlot).toBe(subgraph.inputs[0])
|
||||
expect(subgraphNode.inputs[0]._subgraphSlot).not.toBe(originalSlot)
|
||||
expect(subgraphNode.inputs[0].widgetId).toBe(originalWidgetId)
|
||||
expect(subgraphNode.widgets[0]).toMatchObject({
|
||||
name: 'text',
|
||||
value: 'initial'
|
||||
})
|
||||
})
|
||||
|
||||
it('stores DOM widget metadata from custom promoted host widgets', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'dom', type: 'STRING' }]
|
||||
})
|
||||
|
||||
const interiorNode = new LGraphNode('Interior')
|
||||
const input = interiorNode.addInput('value', 'STRING')
|
||||
input.widget = { name: 'value' }
|
||||
const interiorWidget = interiorNode.addWidget(
|
||||
'text',
|
||||
'value',
|
||||
'initial',
|
||||
() => {}
|
||||
)
|
||||
Object.assign(interiorWidget, { isDOMWidget: true })
|
||||
subgraph.add(interiorNode)
|
||||
subgraph.inputNode.slots[0].connect(interiorNode.inputs[0], interiorNode)
|
||||
const hostWidget = fromPartial<IBaseWidget>({
|
||||
name: 'host',
|
||||
type: 'text',
|
||||
value: 'host value',
|
||||
options: {},
|
||||
y: 0
|
||||
})
|
||||
|
||||
class HostWidgetSubgraphNode extends SubgraphNode {
|
||||
protected override createPromotedHostWidget() {
|
||||
return hostWidget
|
||||
}
|
||||
}
|
||||
|
||||
const subgraphNode = new HostWidgetSubgraphNode(
|
||||
subgraph.rootGraph,
|
||||
subgraph,
|
||||
fromPartial<ExportedSubgraphInstance>({
|
||||
id: 10,
|
||||
type: subgraph.id,
|
||||
pos: [0, 0],
|
||||
size: [200, 100],
|
||||
properties: {}
|
||||
})
|
||||
)
|
||||
const widgetId = subgraphNode.inputs[0].widgetId
|
||||
if (!widgetId) throw new Error('Missing widgetId')
|
||||
|
||||
expect(subgraphNode.widgets).toEqual([hostWidget])
|
||||
expect(useWidgetValueStore().getWidget(widgetId)).toMatchObject({
|
||||
isDOMWidget: true
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('SubgraphNode widget name collision on rename', () => {
|
||||
@@ -846,31 +658,6 @@ describe('SubgraphNode Lifecycle', () => {
|
||||
})
|
||||
|
||||
describe('SubgraphNode Basic Functionality', () => {
|
||||
it('opens subgraphs from the title button and delegates other buttons', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const canvas = fromPartial<
|
||||
Parameters<SubgraphNode['onTitleButtonClick']>[1]
|
||||
>({
|
||||
openSubgraph: vi.fn()
|
||||
})
|
||||
const fallback = vi
|
||||
.spyOn(LGraphNode.prototype, 'onTitleButtonClick')
|
||||
.mockImplementation(() => undefined)
|
||||
|
||||
subgraphNode.onTitleButtonClick(
|
||||
fromPartial({ name: 'enter_subgraph' }),
|
||||
canvas
|
||||
)
|
||||
subgraphNode.onTitleButtonClick(fromPartial({ name: 'other' }), canvas)
|
||||
|
||||
expect(canvas.openSubgraph).toHaveBeenCalledWith(subgraph, subgraphNode)
|
||||
expect(fallback).toHaveBeenCalledWith(
|
||||
fromPartial({ name: 'other' }),
|
||||
canvas
|
||||
)
|
||||
})
|
||||
|
||||
it('should inherit input types correctly', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [
|
||||
@@ -900,157 +687,6 @@ describe('SubgraphNode Basic Functionality', () => {
|
||||
expect(subgraphNode.outputs[1].type).toBe('string')
|
||||
expect(subgraphNode.outputs[2].type).toBe('*')
|
||||
})
|
||||
|
||||
it('delegates title box drawing to a custom handler', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const onDrawTitleBox = vi.fn()
|
||||
subgraphNode.onDrawTitleBox = onDrawTitleBox
|
||||
const ctx = fromPartial<CanvasRenderingContext2D>({})
|
||||
|
||||
subgraphNode.drawTitleBox(ctx, {
|
||||
scale: 2,
|
||||
low_quality: false,
|
||||
title_height: 30,
|
||||
box_size: 12
|
||||
})
|
||||
|
||||
expect(onDrawTitleBox).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
30,
|
||||
subgraphNode.renderingSize,
|
||||
2
|
||||
)
|
||||
})
|
||||
|
||||
it('draws the default title box with and without the bitmap icon', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const ctx = fromPartial<CanvasRenderingContext2D>({
|
||||
save: vi.fn(),
|
||||
beginPath: vi.fn(),
|
||||
roundRect: vi.fn(),
|
||||
fill: vi.fn(),
|
||||
translate: vi.fn(),
|
||||
scale: vi.fn(),
|
||||
drawImage: vi.fn(),
|
||||
restore: vi.fn()
|
||||
})
|
||||
|
||||
subgraphNode.drawTitleBox(ctx, { scale: 1 })
|
||||
subgraphNode.drawTitleBox(ctx, { scale: 1, low_quality: true })
|
||||
|
||||
expect(ctx.roundRect).toHaveBeenCalledWith(6, -24.5, 22, 20, 5)
|
||||
expect(ctx.drawImage).toHaveBeenCalledTimes(1)
|
||||
expect(ctx.restore).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('returns undefined when a widgetId does not match a promoted input', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'text', type: 'STRING' }]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
|
||||
expect(
|
||||
subgraphNode.getSlotFromWidget(
|
||||
fromPartial<IBaseWidget>({
|
||||
name: 'missing',
|
||||
type: 'text',
|
||||
value: '',
|
||||
widgetId: 'missing-widget' as WidgetId
|
||||
})
|
||||
)
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns null for missing inner input links', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
outputs: [{ name: 'output', type: 'IMAGE' }]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
|
||||
expect(subgraphNode.getInputLink(0)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns a translated input link for connected subgraph outputs', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
outputs: [{ name: 'output', type: 'IMAGE' }]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const inner = new LGraphNode('Inner')
|
||||
inner.id = toNodeId(9)
|
||||
inner.addOutput('image', 'IMAGE')
|
||||
subgraph.add(inner)
|
||||
subgraph.outputNode.slots[0].connect(inner.outputs[0], inner)
|
||||
|
||||
const link = subgraphNode.getInputLink(0)
|
||||
|
||||
expect(link?.origin_id).toBe(toNodeId(`${subgraphNode.id}:${inner.id}`))
|
||||
expect(link?.origin_slot).toBe(0)
|
||||
})
|
||||
|
||||
it('returns empty resolved input links when the subgraph input is isolated', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'input', type: 'IMAGE' }]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
|
||||
expect(subgraphNode.resolveSubgraphInputLinks(0)).toEqual([])
|
||||
})
|
||||
|
||||
it('returns resolved input links when the subgraph input is connected', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'input', type: 'IMAGE' }]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const inner = new LGraphNode('Inner')
|
||||
inner.id = toNodeId(9)
|
||||
const input = inner.addInput('image', 'IMAGE')
|
||||
subgraph.add(inner)
|
||||
subgraph.inputNode.slots[0].connect(input, inner)
|
||||
|
||||
expect(subgraphNode.resolveSubgraphInputLinks(0)).toEqual([
|
||||
expect.objectContaining({
|
||||
input,
|
||||
inputNode: inner
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('returns resolved output links when the subgraph output is connected', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
outputs: [{ name: 'output', type: 'IMAGE' }]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const inner = new LGraphNode('Inner')
|
||||
inner.addOutput('image', 'IMAGE')
|
||||
subgraph.add(inner)
|
||||
subgraph.outputNode.slots[0].connect(inner.outputs[0], inner)
|
||||
|
||||
expect(subgraphNode.resolveSubgraphOutputLink(0)?.outputNode).toBe(inner)
|
||||
})
|
||||
|
||||
it('returns a consistent slot shape only when all inner shapes match', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'input', type: 'IMAGE' }]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const slot = subgraph.inputs[0]
|
||||
|
||||
expect(subgraphNode.getSlotShape(slot, fromPartial({ shape: 4 }))).toBe(4)
|
||||
|
||||
const node = new LGraphNode('ShapeTarget')
|
||||
const rounded = node.addInput('rounded', 'IMAGE')
|
||||
const boxed = node.addInput('boxed', 'IMAGE')
|
||||
rounded.shape = 4
|
||||
boxed.shape = 3
|
||||
subgraph.add(node)
|
||||
slot.connect(rounded, node)
|
||||
|
||||
expect(subgraphNode.getSlotShape(slot, boxed)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('SubgraphNode Execution', () => {
|
||||
@@ -1140,27 +776,6 @@ describe('SubgraphNode Execution', () => {
|
||||
expect(() => subgraph.add(subgraphNode)).toThrow()
|
||||
})
|
||||
|
||||
it('throws a recursion error when traversal revisits the same subgraph node', () => {
|
||||
const subgraph = createTestSubgraph({ name: '' })
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
subgraphNode.title = 'Recursive Host'
|
||||
|
||||
expect(() =>
|
||||
subgraphNode.getInnerNodes(new Map(), [], [], new Set([subgraphNode]))
|
||||
).toThrow('Circular reference detected')
|
||||
})
|
||||
|
||||
it('describes unnamed recursive subgraph nodes', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
subgraph.name = ''
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
subgraphNode.title = ''
|
||||
|
||||
expect(() =>
|
||||
subgraphNode.getInnerNodes(new Map(), [], [], new Set([subgraphNode]))
|
||||
).toThrow("node 1 of subgraph 'Unnamed Subgraph'")
|
||||
})
|
||||
|
||||
it('should resolve cross-boundary links', () => {
|
||||
// This test verifies that links can cross subgraph boundaries
|
||||
// Currently this is a basic test - full cross-boundary linking
|
||||
@@ -1186,171 +801,6 @@ describe('SubgraphNode Execution', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('SubgraphNode preview exposure hydration', () => {
|
||||
it('hydrates explicit preview exposure properties', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const store = usePreviewExposureStore()
|
||||
|
||||
subgraphNode.configure({
|
||||
...subgraphNode.serialize(),
|
||||
properties: {
|
||||
previewExposures: [
|
||||
{
|
||||
name: 'preview',
|
||||
sourceNodeId: '12',
|
||||
sourcePreviewName: '$$preview'
|
||||
}
|
||||
]
|
||||
}
|
||||
} as ExportedSubgraphInstance)
|
||||
|
||||
expect(
|
||||
store.getExposures(subgraphNode.rootGraph.id, String(subgraphNode.id))
|
||||
).toEqual([
|
||||
{
|
||||
name: 'preview',
|
||||
sourceNodeId: toNodeId(12),
|
||||
sourcePreviewName: '$$preview'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('clears exposures when an explicit empty property is serialized', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const store = usePreviewExposureStore()
|
||||
store.addExposure(subgraphNode.rootGraph.id, String(subgraphNode.id), {
|
||||
sourceNodeId: '12',
|
||||
sourcePreviewName: '$$preview'
|
||||
})
|
||||
|
||||
subgraphNode.configure({
|
||||
...subgraphNode.serialize(),
|
||||
properties: { previewExposures: [] }
|
||||
} as ExportedSubgraphInstance)
|
||||
|
||||
expect(
|
||||
store.getExposures(subgraphNode.rootGraph.id, String(subgraphNode.id))
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('hydrates legacy locator exposures when no explicit property exists', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const store = usePreviewExposureStore()
|
||||
const legacyLocator = createNodeLocatorId(null, subgraphNode.id)
|
||||
store.addExposure(subgraphNode.rootGraph.id, legacyLocator, {
|
||||
sourceNodeId: '12',
|
||||
sourcePreviewName: '$$legacy'
|
||||
})
|
||||
|
||||
subgraphNode.configure({
|
||||
...subgraphNode.serialize(),
|
||||
properties: {}
|
||||
} as ExportedSubgraphInstance)
|
||||
|
||||
expect(
|
||||
store.getExposures(subgraphNode.rootGraph.id, String(subgraphNode.id))
|
||||
).toEqual([
|
||||
expect.objectContaining({
|
||||
sourceNodeId: toNodeId(12),
|
||||
sourcePreviewName: '$$legacy'
|
||||
})
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('SubgraphNode serialization', () => {
|
||||
it('serializes promoted widget values and valid quarantine entries', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'seed', type: 'INT' }]
|
||||
})
|
||||
const interiorNode = new LGraphNode('Interior')
|
||||
const input = interiorNode.addInput('value', 'INT')
|
||||
input.widget = { name: 'value' }
|
||||
interiorNode.addWidget('number', 'value', 3, () => {})
|
||||
subgraph.add(interiorNode)
|
||||
subgraph.inputNode.slots[0].connect(interiorNode.inputs[0], interiorNode)
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const widgetId = subgraphNode.inputs[0].widgetId
|
||||
if (!widgetId) throw new Error('Missing widgetId')
|
||||
useWidgetValueStore().setValue(widgetId, 42)
|
||||
subgraphNode.properties.proxyWidgetErrorQuarantine = [
|
||||
{
|
||||
originalEntry: ['-1', 'seed'],
|
||||
reason: 'missingSourceNode',
|
||||
attemptedAtVersion: 1,
|
||||
hostValue: 7
|
||||
}
|
||||
]
|
||||
|
||||
const serialized = subgraphNode.serialize()
|
||||
|
||||
expect(serialized.widgets_values).toEqual([42])
|
||||
expect(serialized.properties?.proxyWidgetErrorQuarantine).toEqual([
|
||||
{
|
||||
originalEntry: ['-1', 'seed'],
|
||||
reason: 'missingSourceNode',
|
||||
attemptedAtVersion: 1,
|
||||
hostValue: 7
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('uses quarantined host values before serialized widget values', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'seed', type: 'INT' }]
|
||||
})
|
||||
const interiorNode = new LGraphNode('Interior')
|
||||
const input = interiorNode.addInput('value', 'INT')
|
||||
input.widget = { name: 'value' }
|
||||
interiorNode.addWidget('number', 'value', 3, () => {})
|
||||
subgraph.add(interiorNode)
|
||||
subgraph.inputNode.slots[0].connect(interiorNode.inputs[0], interiorNode)
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const widgetId = subgraphNode.inputs[0].widgetId
|
||||
if (!widgetId) throw new Error('Missing widgetId')
|
||||
|
||||
subgraphNode.configure({
|
||||
...subgraphNode.serialize(),
|
||||
widgets_values: [11],
|
||||
properties: {
|
||||
proxyWidgetErrorQuarantine: [
|
||||
{
|
||||
originalEntry: ['-1', 'seed'],
|
||||
reason: 'missingSourceNode',
|
||||
attemptedAtVersion: 1,
|
||||
hostValue: 55
|
||||
}
|
||||
]
|
||||
}
|
||||
} as ExportedSubgraphInstance)
|
||||
|
||||
expect(useWidgetValueStore().getWidget(widgetId)?.value).toBe(55)
|
||||
})
|
||||
|
||||
it('omits widget values when promoted widget state is non-serializable', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'seed', type: 'INT' }]
|
||||
})
|
||||
const interiorNode = new LGraphNode('Interior')
|
||||
const input = interiorNode.addInput('value', 'INT')
|
||||
input.widget = { name: 'value' }
|
||||
interiorNode.addWidget('number', 'value', 3, () => {})
|
||||
subgraph.add(interiorNode)
|
||||
subgraph.inputNode.slots[0].connect(interiorNode.inputs[0], interiorNode)
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const widgetId = subgraphNode.inputs[0].widgetId
|
||||
if (!widgetId) throw new Error('Missing widgetId')
|
||||
useWidgetValueStore().getWidget(widgetId)!.value = undefined
|
||||
|
||||
const serialized = subgraphNode.serialize()
|
||||
|
||||
expect(serialized.widgets_values).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('SubgraphNode Edge Cases', () => {
|
||||
it('should handle deep nesting', () => {
|
||||
// Create a simpler deep nesting test that works with current implementation
|
||||
@@ -1501,26 +951,6 @@ describe('SubgraphNode Cleanup', () => {
|
||||
expect(abortSpy1).toHaveBeenCalledTimes(1)
|
||||
expect(abortSpy2).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('removes promoted widgets even when an input listener is absent', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'input', type: 'number' }]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const onRemove = vi.fn()
|
||||
subgraphNode.inputs[0]._widget = fromPartial<IBaseWidget>({
|
||||
name: 'input',
|
||||
type: 'number',
|
||||
options: {},
|
||||
y: 0,
|
||||
onRemove
|
||||
})
|
||||
delete subgraphNode.inputs[0]._listenerController
|
||||
|
||||
subgraphNode.onRemoved()
|
||||
|
||||
expect(onRemove).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('SubgraphNode duplicate input pruning (#9977)', () => {
|
||||
@@ -1646,49 +1076,6 @@ describe('Nested SubgraphNode duplicate input prevention', () => {
|
||||
expect(node.inputs).toHaveLength(2)
|
||||
expect(node.inputs.map((i) => i.name)).toEqual(['x', 'y'])
|
||||
})
|
||||
|
||||
it('rebinds duplicate serialized inputs by signature and then by name', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [
|
||||
{ name: 'same', type: 'STRING' },
|
||||
{ name: 'same', type: 'STRING' },
|
||||
{ name: 'loose', type: 'INT' }
|
||||
]
|
||||
})
|
||||
|
||||
const node = new SubgraphNode(
|
||||
subgraph.rootGraph,
|
||||
subgraph,
|
||||
fromPartial<ExportedSubgraphInstance>({
|
||||
id: 1,
|
||||
type: subgraph.id,
|
||||
pos: [0, 0],
|
||||
size: [200, 100],
|
||||
inputs: [
|
||||
{ name: 'same', type: 'STRING', link: null },
|
||||
{ name: 'same', type: 'STRING', link: null },
|
||||
{ name: 'loose', type: 'FLOAT', link: null },
|
||||
{ name: 'missing', type: 'BOOLEAN', link: null }
|
||||
],
|
||||
outputs: [],
|
||||
properties: {},
|
||||
flags: {},
|
||||
mode: 0,
|
||||
order: 0
|
||||
})
|
||||
)
|
||||
|
||||
expect(node.inputs.map((input) => input.name)).toEqual([
|
||||
'same',
|
||||
'same',
|
||||
'loose'
|
||||
])
|
||||
expect(node.inputs.map((input) => input._subgraphSlot)).toEqual([
|
||||
subgraph.inputs[0],
|
||||
subgraph.inputs[1],
|
||||
subgraph.inputs[2]
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('SubgraphNode label propagation', () => {
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer'
|
||||
import { LLink } from '@/lib/litegraph/src/LLink'
|
||||
import type {
|
||||
DefaultConnectionColors,
|
||||
INodeOutputSlot
|
||||
} from '@/lib/litegraph/src/interfaces'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LinkConnector } from '@/lib/litegraph/src/canvas/LinkConnector'
|
||||
import type { NodeLike } from '@/lib/litegraph/src/types/NodeLike'
|
||||
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
|
||||
import { toLinkId } from '@/types/linkId'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import { toRerouteId } from '@/types/rerouteId'
|
||||
|
||||
import { createTestSubgraph } from './__fixtures__/subgraphHelpers'
|
||||
|
||||
function eventAt(x: number, y: number, button = 0): CanvasPointerEvent {
|
||||
return { canvasX: x, canvasY: y, button } as CanvasPointerEvent
|
||||
}
|
||||
|
||||
function createCanvasContext() {
|
||||
return {
|
||||
getTransform: vi.fn(() => new DOMMatrix()),
|
||||
translate: vi.fn(),
|
||||
beginPath: vi.fn(),
|
||||
arc: vi.fn(),
|
||||
moveTo: vi.fn(),
|
||||
lineTo: vi.fn(),
|
||||
stroke: vi.fn(),
|
||||
setTransform: vi.fn(),
|
||||
rect: vi.fn(),
|
||||
fill: vi.fn(),
|
||||
fillText: vi.fn(),
|
||||
strokeStyle: '',
|
||||
lineWidth: 1,
|
||||
font: '',
|
||||
fillStyle: '',
|
||||
textBaseline: '',
|
||||
globalAlpha: 1
|
||||
} as unknown as CanvasRenderingContext2D
|
||||
}
|
||||
|
||||
describe('SubgraphOutputNode', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('exposes output slots plus the empty slot and computes its anchor', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
outputs: [{ name: 'image', type: 'IMAGE' }]
|
||||
})
|
||||
subgraph.outputNode.configure({
|
||||
id: subgraph.outputNode.id,
|
||||
bounding: [10, 20, 100, 80],
|
||||
pinned: false
|
||||
})
|
||||
|
||||
expect(subgraph.outputNode.slots).toBe(subgraph.outputs)
|
||||
expect(subgraph.outputNode.allSlots).toEqual([
|
||||
subgraph.outputs[0],
|
||||
subgraph.outputNode.emptySlot
|
||||
])
|
||||
expect(subgraph.outputNode.slotAnchorX).toBe(24)
|
||||
})
|
||||
|
||||
it('sets link connector drag callbacks for left-clicked slots', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
outputs: [{ name: 'image', type: 'IMAGE' }]
|
||||
})
|
||||
const slot = subgraph.outputs[0]
|
||||
slot.boundingRect.updateTo([10, 20, 100, 30])
|
||||
const pointer = {} as CanvasPointer
|
||||
const linkConnector = {
|
||||
dragNewFromSubgraphOutput: vi.fn(),
|
||||
dropLinks: vi.fn(),
|
||||
reset: vi.fn()
|
||||
} as unknown as LinkConnector
|
||||
|
||||
subgraph.outputNode.onPointerDown(eventAt(20, 25), pointer, linkConnector)
|
||||
|
||||
pointer.onDragStart?.(pointer)
|
||||
pointer.onDragEnd?.(eventAt(40, 45))
|
||||
pointer.finally?.()
|
||||
|
||||
expect(linkConnector.dragNewFromSubgraphOutput).toHaveBeenCalledWith(
|
||||
subgraph,
|
||||
subgraph.outputNode,
|
||||
slot
|
||||
)
|
||||
expect(linkConnector.dropLinks).toHaveBeenCalledWith(
|
||||
subgraph,
|
||||
expect.objectContaining({ canvasX: 40 })
|
||||
)
|
||||
expect(linkConnector.reset).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('opens the slot context menu for right-clicked slots', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
outputs: [{ name: 'image', type: 'IMAGE' }]
|
||||
})
|
||||
const slot = subgraph.outputs[0]
|
||||
slot.boundingRect.updateTo([10, 20, 100, 30])
|
||||
const menuSpy = vi.spyOn(
|
||||
subgraph.outputNode as unknown as {
|
||||
showSlotContextMenu(slot: unknown, event: unknown): void
|
||||
},
|
||||
'showSlotContextMenu'
|
||||
)
|
||||
|
||||
subgraph.outputNode.onPointerDown(
|
||||
eventAt(20, 25, 2),
|
||||
{} as CanvasPointer,
|
||||
{} as LinkConnector
|
||||
)
|
||||
subgraph.outputNode.onPointerDown(
|
||||
eventAt(500, 500, 2),
|
||||
{} as CanvasPointer,
|
||||
{} as LinkConnector
|
||||
)
|
||||
|
||||
expect(menuSpy).toHaveBeenCalledOnce()
|
||||
expect(menuSpy).toHaveBeenCalledWith(
|
||||
slot,
|
||||
expect.objectContaining({ button: 2 })
|
||||
)
|
||||
})
|
||||
|
||||
it('renames and removes output slots through the parent subgraph', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
outputs: [{ name: 'image', type: 'IMAGE' }]
|
||||
})
|
||||
const slot = subgraph.outputs[0]
|
||||
const renameSpy = vi.spyOn(subgraph, 'renameOutput')
|
||||
const removeSpy = vi.spyOn(subgraph, 'removeOutput')
|
||||
|
||||
subgraph.outputNode.renameSlot(slot, 'preview')
|
||||
subgraph.outputNode.removeSlot(slot)
|
||||
|
||||
expect(renameSpy).toHaveBeenCalledWith(slot, 'preview')
|
||||
expect(removeSpy).toHaveBeenCalledWith(slot)
|
||||
})
|
||||
|
||||
it('delegates connection checks and output-type connections', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
outputs: [{ name: 'image', type: 'IMAGE' }]
|
||||
})
|
||||
const slot = subgraph.outputs[0]
|
||||
const outputSlot = {
|
||||
index: 0,
|
||||
slot: { name: 'out', type: 'IMAGE' }
|
||||
} as unknown as { index: number; slot: INodeOutputSlot }
|
||||
const targetNode = new LGraphNode('Target')
|
||||
targetNode.id = toNodeId(99)
|
||||
vi.spyOn(targetNode, 'findOutputByType').mockReturnValue(outputSlot)
|
||||
const link = new LLink(toLinkId(1), 'IMAGE', toNodeId(1), 0, toNodeId(2), 0)
|
||||
const connectSpy = vi.spyOn(slot, 'connect').mockReturnValue(link)
|
||||
const outputNode = fromPartial<NodeLike>({
|
||||
canConnectTo: vi.fn(() => true)
|
||||
})
|
||||
|
||||
expect(
|
||||
subgraph.outputNode.canConnectTo(outputNode, slot, outputSlot.slot)
|
||||
).toBe(true)
|
||||
expect(
|
||||
subgraph.outputNode.connectByTypeOutput(0, targetNode, 'IMAGE', {
|
||||
afterRerouteId: toRerouteId(7)
|
||||
})
|
||||
).toBe(link)
|
||||
expect(connectSpy).toHaveBeenCalledWith(
|
||||
outputSlot.slot,
|
||||
targetNode,
|
||||
toRerouteId(7)
|
||||
)
|
||||
|
||||
vi.mocked(targetNode.findOutputByType).mockReturnValue(undefined)
|
||||
expect(
|
||||
subgraph.outputNode.connectByTypeOutput(0, targetNode, 'LATENT')
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('finds the first free output slot of a matching type', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
outputs: [
|
||||
{ name: 'used', type: 'IMAGE' },
|
||||
{ name: 'free', type: 'IMAGE' }
|
||||
]
|
||||
})
|
||||
subgraph.outputs[0].linkIds.push(toLinkId(1))
|
||||
|
||||
expect(subgraph.outputNode.findInputByType('IMAGE')).toBe(
|
||||
subgraph.outputs[0]
|
||||
)
|
||||
expect(subgraph.outputNode.findInputByType('LATENT')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('draws the side rail and output slots', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
outputs: [{ name: 'image', type: 'IMAGE' }]
|
||||
})
|
||||
subgraph.outputNode.configure({
|
||||
id: subgraph.outputNode.id,
|
||||
bounding: [10, 20, 100, 80],
|
||||
pinned: false
|
||||
})
|
||||
const ctx = createCanvasContext()
|
||||
const drawSlotsSpy = vi.spyOn(
|
||||
subgraph.outputNode as unknown as {
|
||||
drawSlots(
|
||||
ctx: unknown,
|
||||
colorContext: unknown,
|
||||
fromSlot: unknown,
|
||||
editorAlpha: unknown
|
||||
): void
|
||||
},
|
||||
'drawSlots'
|
||||
)
|
||||
|
||||
subgraph.outputNode.drawProtected(
|
||||
ctx,
|
||||
{
|
||||
getConnectedColor: vi.fn(() => '#fff'),
|
||||
getDisconnectedColor: vi.fn(() => '#000')
|
||||
} as unknown as DefaultConnectionColors,
|
||||
subgraph.outputs[0],
|
||||
0.5
|
||||
)
|
||||
|
||||
expect(ctx.translate).toHaveBeenCalledWith(10, 20)
|
||||
expect(ctx.beginPath).toHaveBeenCalled()
|
||||
expect(ctx.stroke).toHaveBeenCalled()
|
||||
expect(ctx.setTransform).toHaveBeenCalled()
|
||||
expect(drawSlotsSpy).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
getConnectedColor: expect.any(Function),
|
||||
getDisconnectedColor: expect.any(Function)
|
||||
}),
|
||||
subgraph.outputs[0],
|
||||
0.5
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,168 +0,0 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LLink } from '@/lib/litegraph/src/LLink'
|
||||
import type {
|
||||
INodeInputSlot,
|
||||
INodeOutputSlot
|
||||
} from '@/lib/litegraph/src/interfaces'
|
||||
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { toLinkId } from '@/types/linkId'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import { toRerouteId } from '@/types/rerouteId'
|
||||
|
||||
import { createTestSubgraph } from './__fixtures__/subgraphHelpers'
|
||||
|
||||
describe('SubgraphOutput', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('connects node outputs to subgraph outputs', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
outputs: [{ name: 'preview', type: 'IMAGE' }]
|
||||
})
|
||||
const sourceNode = new LGraphNode('Source')
|
||||
sourceNode.id = toNodeId(10)
|
||||
subgraph.add(sourceNode)
|
||||
const output = sourceNode.addOutput('image', 'IMAGE')
|
||||
const afterChangeSpy = vi.spyOn(subgraph, 'afterChange')
|
||||
const connectionSpy = vi.fn()
|
||||
sourceNode.onConnectionsChange = connectionSpy
|
||||
|
||||
const link = subgraph.outputs[0].connect(output, sourceNode, toRerouteId(5))
|
||||
|
||||
expect(link).toBeInstanceOf(LLink)
|
||||
expect(link?.origin_id).toBe(sourceNode.id)
|
||||
expect(link?.target_id).toBe(subgraph.outputNode.id)
|
||||
expect(link?.parentId).toBe(toRerouteId(5))
|
||||
expect(subgraph.outputs[0].linkIds).toEqual([link?.id])
|
||||
expect(output.links).toEqual([link?.id])
|
||||
expect(subgraph.getLink(link?.id ?? toLinkId(-1))).toBe(link)
|
||||
expect(connectionSpy).toHaveBeenCalledWith(
|
||||
NodeSlotType.OUTPUT,
|
||||
0,
|
||||
true,
|
||||
link,
|
||||
output
|
||||
)
|
||||
expect(afterChangeSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not connect incompatible or blocked node outputs', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
outputs: [{ name: 'preview', type: 'IMAGE' }]
|
||||
})
|
||||
const sourceNode = new LGraphNode('Source')
|
||||
const latentOutput = sourceNode.addOutput('latent', 'LATENT')
|
||||
|
||||
expect(
|
||||
subgraph.outputs[0].connect(latentOutput, sourceNode)
|
||||
).toBeUndefined()
|
||||
|
||||
const imageOutput = sourceNode.addOutput('image', 'IMAGE')
|
||||
sourceNode.onConnectOutput = vi.fn(() => false)
|
||||
|
||||
expect(subgraph.outputs[0].connect(imageOutput, sourceNode)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('throws when the output slot is not owned by the node', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
outputs: [{ name: 'preview', type: 'IMAGE' }]
|
||||
})
|
||||
const sourceNode = new LGraphNode('Source')
|
||||
const foreignOutput = { name: 'image', type: 'IMAGE' } as INodeOutputSlot
|
||||
|
||||
expect(() =>
|
||||
subgraph.outputs[0].connect(foreignOutput, sourceNode)
|
||||
).toThrow('Slot is not an output of the given node')
|
||||
})
|
||||
|
||||
it('disconnects existing links before accepting a replacement', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
outputs: [{ name: 'preview', type: 'IMAGE' }]
|
||||
})
|
||||
const firstNode = new LGraphNode('First')
|
||||
firstNode.id = toNodeId(10)
|
||||
subgraph.add(firstNode)
|
||||
const firstOutput = firstNode.addOutput('image', 'IMAGE')
|
||||
const firstLink = subgraph.outputs[0].connect(firstOutput, firstNode)
|
||||
const secondNode = new LGraphNode('Second')
|
||||
secondNode.id = toNodeId(11)
|
||||
subgraph.add(secondNode)
|
||||
const secondOutput = secondNode.addOutput('image', 'IMAGE')
|
||||
const beforeChangeSpy = vi.spyOn(subgraph, 'beforeChange')
|
||||
|
||||
const secondLink = subgraph.outputs[0].connect(secondOutput, secondNode)
|
||||
|
||||
expect(beforeChangeSpy).toHaveBeenCalled()
|
||||
expect(firstOutput.links).not.toContain(firstLink?.id)
|
||||
expect(subgraph.outputs[0].linkIds).toEqual([secondLink?.id])
|
||||
expect(secondOutput.links).toEqual([secondLink?.id])
|
||||
})
|
||||
|
||||
it('arranges and labels from the left edge', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
outputs: [{ name: 'preview', type: 'IMAGE' }]
|
||||
})
|
||||
const output = subgraph.outputs[0]
|
||||
|
||||
output.arrange([20, 30, 120, 40])
|
||||
|
||||
expect(Array.from(output.boundingRect)).toEqual([20, 30, 120, 40])
|
||||
expect(output.pos).toEqual([40, 50])
|
||||
expect(output.labelPos).toEqual([60, 50])
|
||||
})
|
||||
|
||||
it('validates output slots and subgraph inputs as targets', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'source', type: 'IMAGE' }],
|
||||
outputs: [{ name: 'preview', type: 'IMAGE' }]
|
||||
})
|
||||
const output = subgraph.outputs[0]
|
||||
const imageOutput = fromPartial<INodeOutputSlot>({
|
||||
name: 'image',
|
||||
type: 'IMAGE',
|
||||
links: []
|
||||
})
|
||||
const latentOutput = fromPartial<INodeOutputSlot>({
|
||||
name: 'latent',
|
||||
type: 'LATENT',
|
||||
links: []
|
||||
})
|
||||
const imageInput = { name: 'image', type: 'IMAGE', link: null }
|
||||
|
||||
expect(output.isValidTarget(imageOutput)).toBe(true)
|
||||
expect(output.isValidTarget(latentOutput)).toBe(false)
|
||||
expect(output.isValidTarget(imageInput as INodeInputSlot)).toBe(false)
|
||||
expect(output.isValidTarget(subgraph.inputs[0])).toBe(true)
|
||||
})
|
||||
|
||||
it('disconnects links and notifies output nodes', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
outputs: [{ name: 'preview', type: 'IMAGE' }]
|
||||
})
|
||||
const sourceNode = new LGraphNode('Source')
|
||||
sourceNode.id = toNodeId(10)
|
||||
subgraph.add(sourceNode)
|
||||
const output = sourceNode.addOutput('image', 'IMAGE')
|
||||
const link = subgraph.outputs[0].connect(output, sourceNode)
|
||||
const removeLinkSpy = vi.spyOn(subgraph, 'removeLink')
|
||||
const connectionSpy = vi.fn()
|
||||
sourceNode.onConnectionsChange = connectionSpy
|
||||
|
||||
subgraph.outputs[0].disconnect()
|
||||
|
||||
expect(removeLinkSpy).toHaveBeenCalledWith(link?.id)
|
||||
expect(output.links).not.toContain(link?.id)
|
||||
expect(connectionSpy).toHaveBeenCalledWith(
|
||||
NodeSlotType.OUTPUT,
|
||||
0,
|
||||
false,
|
||||
link,
|
||||
subgraph.outputs[0]
|
||||
)
|
||||
expect(subgraph.outputs[0].linkIds).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -2,18 +2,11 @@ import {
|
||||
SUBGRAPH_INPUT_ID,
|
||||
SUBGRAPH_OUTPUT_ID
|
||||
} from '@/lib/litegraph/src/constants'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { LGraphState } from '@/lib/litegraph/src/LGraph'
|
||||
import { toLinkId } from '@/types/linkId'
|
||||
import { toRerouteId } from '@/types/rerouteId'
|
||||
import type { ExportedSubgraph } from '../types/serialisation'
|
||||
|
||||
import type { ExportedSubgraph, ISerialisedNode } from '../types/serialisation'
|
||||
|
||||
import {
|
||||
deduplicateSubgraphNodeIds,
|
||||
topologicalSortSubgraphs
|
||||
} from './subgraphDeduplication'
|
||||
import { topologicalSortSubgraphs } from './subgraphDeduplication'
|
||||
|
||||
function makeSubgraph(id: string, nodeTypes: string[] = []): ExportedSubgraph {
|
||||
return {
|
||||
@@ -39,196 +32,6 @@ function makeSubgraph(id: string, nodeTypes: string[] = []): ExportedSubgraph {
|
||||
} as ExportedSubgraph
|
||||
}
|
||||
|
||||
describe('deduplicateSubgraphNodeIds', () => {
|
||||
it('remaps duplicate IDs in nodes, links, promoted widgets, and root proxy widgets', () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const subgraph = makeSubgraph('inner')
|
||||
subgraph.nodes = [
|
||||
{
|
||||
id: 1,
|
||||
type: 'Source',
|
||||
pos: [0, 0],
|
||||
size: [100, 100],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0,
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
properties: {}
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: 'Target',
|
||||
pos: [0, 0],
|
||||
size: [100, 100],
|
||||
flags: {},
|
||||
order: 1,
|
||||
mode: 0,
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
properties: {}
|
||||
}
|
||||
]
|
||||
subgraph.links = [
|
||||
{
|
||||
id: 1,
|
||||
origin_id: 1,
|
||||
origin_slot: 0,
|
||||
target_id: 2,
|
||||
target_slot: 0,
|
||||
type: '*'
|
||||
}
|
||||
]
|
||||
subgraph.widgets = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'text'
|
||||
}
|
||||
]
|
||||
const rootNodes: ISerialisedNode[] = [
|
||||
{
|
||||
id: 10,
|
||||
type: 'inner',
|
||||
pos: [0, 0],
|
||||
size: [100, 100],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0,
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
properties: {
|
||||
proxyWidgets: [[1, 'text'], 'not-an-entry']
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
type: 'Other',
|
||||
pos: [0, 0],
|
||||
size: [100, 100],
|
||||
flags: {},
|
||||
order: 1,
|
||||
mode: 0,
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
properties: {
|
||||
proxyWidgets: [[1, 'text']]
|
||||
}
|
||||
}
|
||||
]
|
||||
const state: LGraphState = {
|
||||
lastNodeId: 2,
|
||||
lastLinkId: toLinkId(0),
|
||||
lastGroupId: 0,
|
||||
lastRerouteId: toRerouteId(0)
|
||||
}
|
||||
|
||||
const result = deduplicateSubgraphNodeIds(
|
||||
[subgraph],
|
||||
new Set([1]),
|
||||
state,
|
||||
rootNodes
|
||||
)
|
||||
|
||||
expect(result.subgraphs[0].nodes?.[0].id).toBe(3)
|
||||
expect(result.subgraphs[0].links?.[0]).toMatchObject({
|
||||
origin_id: 3,
|
||||
target_id: 2
|
||||
})
|
||||
expect(result.subgraphs[0].widgets?.[0].id).toBe(3)
|
||||
expect(result.rootNodes?.[0].properties?.proxyWidgets).toEqual([
|
||||
['3', 'text'],
|
||||
'not-an-entry'
|
||||
])
|
||||
expect(result.rootNodes?.[1].properties?.proxyWidgets).toEqual([
|
||||
[1, 'text']
|
||||
])
|
||||
expect(subgraph.nodes?.[0].id).toBe(1)
|
||||
expect(rootNodes[0].properties?.proxyWidgets).toEqual([
|
||||
[1, 'text'],
|
||||
'not-an-entry'
|
||||
])
|
||||
expect(state.lastNodeId).toBe(3)
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'LiteGraph: duplicate subgraph node ID 1 remapped to 3'
|
||||
)
|
||||
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('tracks numeric IDs without root nodes and ignores non-numeric IDs', () => {
|
||||
const subgraph = makeSubgraph('ids')
|
||||
subgraph.nodes = [
|
||||
{
|
||||
id: '9',
|
||||
type: 'NumericString',
|
||||
pos: [0, 0],
|
||||
size: [100, 100],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0,
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
properties: {}
|
||||
},
|
||||
{
|
||||
id: 'alpha',
|
||||
type: 'NamedNode',
|
||||
pos: [0, 0],
|
||||
size: [100, 100],
|
||||
flags: {},
|
||||
order: 1,
|
||||
mode: 0,
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
properties: {}
|
||||
}
|
||||
]
|
||||
const state: LGraphState = {
|
||||
lastNodeId: 1,
|
||||
lastLinkId: toLinkId(0),
|
||||
lastGroupId: 0,
|
||||
lastRerouteId: toRerouteId(0)
|
||||
}
|
||||
|
||||
const result = deduplicateSubgraphNodeIds([subgraph], new Set(), state)
|
||||
|
||||
expect(result.rootNodes).toBeUndefined()
|
||||
expect(result.subgraphs[0].nodes?.map((node) => node.id)).toEqual([
|
||||
'9',
|
||||
'alpha'
|
||||
])
|
||||
expect(state.lastNodeId).toBe(9)
|
||||
})
|
||||
|
||||
it('throws when the numeric node ID space is exhausted', () => {
|
||||
const subgraph = makeSubgraph('full')
|
||||
subgraph.nodes = [
|
||||
{
|
||||
id: 1,
|
||||
type: 'Duplicate',
|
||||
pos: [0, 0],
|
||||
size: [100, 100],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0,
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
properties: {}
|
||||
}
|
||||
]
|
||||
const state: LGraphState = {
|
||||
lastNodeId: 100_000_000,
|
||||
lastLinkId: toLinkId(0),
|
||||
lastGroupId: 0,
|
||||
lastRerouteId: toRerouteId(0)
|
||||
}
|
||||
|
||||
expect(() =>
|
||||
deduplicateSubgraphNodeIds([subgraph], new Set([1]), state)
|
||||
).toThrow('Node ID space exhausted')
|
||||
})
|
||||
})
|
||||
|
||||
describe('topologicalSortSubgraphs', () => {
|
||||
it('returns original order when there are no dependencies', () => {
|
||||
const a = makeSubgraph('a')
|
||||
@@ -274,11 +77,4 @@ describe('topologicalSortSubgraphs', () => {
|
||||
it('returns original order for empty array', () => {
|
||||
expect(topologicalSortSubgraphs([])).toEqual([])
|
||||
})
|
||||
|
||||
it('returns original order when dependencies contain a cycle', () => {
|
||||
const a = makeSubgraph('a', ['b'])
|
||||
const b = makeSubgraph('b', ['a'])
|
||||
|
||||
expect(topologicalSortSubgraphs([a, b])).toEqual([a, b])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,57 +1,26 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
LGraph,
|
||||
LGraphGroup,
|
||||
findUsedSubgraphIds,
|
||||
getDirectSubgraphIds,
|
||||
LGraphNode,
|
||||
LLink,
|
||||
Reroute
|
||||
getDirectSubgraphIds
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { ResolvedConnection } from '@/lib/litegraph/src/LLink'
|
||||
import type { Positionable } from '@/lib/litegraph/src/interfaces'
|
||||
import type { UUID } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SerialisableLLink } from '@/lib/litegraph/src/types/serialisation'
|
||||
import { SUBGRAPH_INPUT_ID } from '@/lib/litegraph/src/constants'
|
||||
import { toLinkId } from '@/types/linkId'
|
||||
import { toRerouteId } from '@/types/rerouteId'
|
||||
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode,
|
||||
resetSubgraphFixtureState
|
||||
} from './__fixtures__/subgraphHelpers'
|
||||
import {
|
||||
getBoundaryLinks,
|
||||
groupResolvedByOutput,
|
||||
isNodeSlot,
|
||||
isSubgraphInput,
|
||||
isSubgraphOutput,
|
||||
mapSubgraphInputsAndLinks,
|
||||
mapSubgraphOutputsAndLinks,
|
||||
multiClone,
|
||||
reorderSubgraphInputs,
|
||||
splitPositionables
|
||||
} from './subgraphUtils'
|
||||
|
||||
describe('subgraphUtils', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
resetSubgraphFixtureState()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
function makeNode(title: string): LGraphNode {
|
||||
const node = new LGraphNode(title)
|
||||
node.addInput('in', 'STRING')
|
||||
node.addOutput('out', 'STRING')
|
||||
return node
|
||||
}
|
||||
|
||||
describe('getDirectSubgraphIds', () => {
|
||||
it('should return empty set for graph with no subgraph nodes', () => {
|
||||
const graph = new LGraph()
|
||||
@@ -175,446 +144,5 @@ describe('subgraphUtils', () => {
|
||||
expect(result.has(subgraph1.id)).toBe(true)
|
||||
expect(result.has(subgraph2.id)).toBe(true) // Still found, just can't recurse into it
|
||||
})
|
||||
|
||||
it('does not revisit subgraphs that were already discovered', () => {
|
||||
const rootGraph = new LGraph()
|
||||
const shared = createTestSubgraph({ name: 'Shared' })
|
||||
const nestedParent = createTestSubgraph({ name: 'Nested parent' })
|
||||
rootGraph.add(createTestSubgraphNode(shared))
|
||||
rootGraph.add(createTestSubgraphNode(nestedParent))
|
||||
nestedParent.add(createTestSubgraphNode(shared))
|
||||
|
||||
const result = findUsedSubgraphIds(
|
||||
rootGraph,
|
||||
new Map([
|
||||
[shared.id, shared],
|
||||
[nestedParent.id, nestedParent]
|
||||
])
|
||||
)
|
||||
|
||||
expect([...result]).toEqual([shared.id, nestedParent.id])
|
||||
})
|
||||
})
|
||||
|
||||
describe('splitPositionables', () => {
|
||||
it('places each known positionable type into its own set', () => {
|
||||
const subgraph = createTestSubgraph({ inputCount: 1, outputCount: 1 })
|
||||
const node = new LGraphNode('Node')
|
||||
const group = new LGraphGroup('Group')
|
||||
const reroute = new Reroute(toRerouteId(1), new LGraph())
|
||||
const unknown = fromPartial<Positionable>({ boundingRect: [0, 0, 1, 1] })
|
||||
|
||||
const result = splitPositionables([
|
||||
node,
|
||||
group,
|
||||
reroute,
|
||||
subgraph.inputNode,
|
||||
subgraph.outputNode,
|
||||
unknown
|
||||
])
|
||||
|
||||
expect(result.nodes.has(node)).toBe(true)
|
||||
expect(result.groups.has(group)).toBe(true)
|
||||
expect(result.reroutes.has(reroute)).toBe(true)
|
||||
expect(result.subgraphInputNodes.has(subgraph.inputNode)).toBe(true)
|
||||
expect(result.subgraphOutputNodes.has(subgraph.outputNode)).toBe(true)
|
||||
expect(result.unknown.has(unknown)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getBoundaryLinks', () => {
|
||||
it('classifies selected node links by internal and boundary direction', () => {
|
||||
const graph = new LGraph()
|
||||
const source = makeNode('Source')
|
||||
const selected = makeNode('Selected')
|
||||
const selectedTarget = makeNode('Selected target')
|
||||
const externalTarget = makeNode('External target')
|
||||
graph.add(source)
|
||||
graph.add(selected)
|
||||
graph.add(selectedTarget)
|
||||
graph.add(externalTarget)
|
||||
|
||||
const boundaryInput = source.connect(0, selected, 0)!
|
||||
const internal = selected.connect(0, selectedTarget, 0)!
|
||||
const boundaryOutput = selected.connect(0, externalTarget, 0)!
|
||||
|
||||
const result = getBoundaryLinks(
|
||||
graph,
|
||||
new Set([selected, selectedTarget])
|
||||
)
|
||||
|
||||
expect(result.boundaryInputLinks).toEqual([boundaryInput])
|
||||
expect(result.internalLinks).toEqual([internal])
|
||||
expect(result.boundaryOutputLinks).toEqual([boundaryOutput])
|
||||
expect(result.boundaryLinks).toEqual([])
|
||||
expect(result.boundaryFloatingLinks).toEqual([])
|
||||
})
|
||||
|
||||
it('ignores unresolved input links and warns with the missing id', () => {
|
||||
const graph = new LGraph()
|
||||
const node = makeNode('Node')
|
||||
graph.add(node)
|
||||
node.inputs[0].link = toLinkId(404)
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
const result = getBoundaryLinks(graph, new Set([node]))
|
||||
|
||||
expect(result.internalLinks).toEqual([])
|
||||
expect(warn).toHaveBeenCalledWith('Failed to resolve link ID [404]')
|
||||
})
|
||||
|
||||
it('treats reroutes with outside participants as boundary links', () => {
|
||||
const graph = new LGraph()
|
||||
const source = makeNode('Source')
|
||||
const target = makeNode('Target')
|
||||
graph.add(source)
|
||||
graph.add(target)
|
||||
const link = source.connect(0, target, 0)!
|
||||
const reroute = new Reroute(toRerouteId(1), graph, [10, 10], undefined, [
|
||||
link.id
|
||||
])
|
||||
link.parentId = reroute.id
|
||||
graph.reroutes.set(reroute.id, reroute)
|
||||
|
||||
const result = getBoundaryLinks(graph, new Set([reroute]))
|
||||
|
||||
expect(result.boundaryLinks).toEqual([link])
|
||||
})
|
||||
|
||||
it('handles unlinked nodes, groups, subgraph-input links, and floating links', () => {
|
||||
const graph = new LGraph()
|
||||
const selected = makeNode('Selected')
|
||||
const group = new LGraphGroup('Group')
|
||||
graph.add(selected)
|
||||
const subgraphInputLink = new LLink(
|
||||
toLinkId(80),
|
||||
'STRING',
|
||||
SUBGRAPH_INPUT_ID,
|
||||
0,
|
||||
selected.id,
|
||||
0
|
||||
)
|
||||
graph.links.set(subgraphInputLink.id, subgraphInputLink)
|
||||
selected.inputs[0].link = subgraphInputLink.id
|
||||
const floatingLink = new LLink(toLinkId(81), 'STRING', 1, 0, 2, 0)
|
||||
const outsideReroute = new Reroute(toRerouteId(8), graph, [0, 0])
|
||||
floatingLink.parentId = outsideReroute.id
|
||||
graph.reroutes.set(outsideReroute.id, outsideReroute)
|
||||
selected.outputs[0]._floatingLinks = new Set([floatingLink])
|
||||
|
||||
const result = getBoundaryLinks(graph, new Set([selected, group]))
|
||||
|
||||
expect(result.boundaryInputLinks).toEqual([subgraphInputLink])
|
||||
expect(result.boundaryFloatingLinks).toEqual([floatingLink])
|
||||
expect(result.boundaryOutputLinks).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('multiClone', () => {
|
||||
it('falls back to cloned serialized data when a node type cannot be created', () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const node = new LGraphNode('Fallback')
|
||||
node.type = 'missing/type'
|
||||
node.properties = { nested: { value: 1 } }
|
||||
|
||||
const result = multiClone([node])
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]).toMatchObject({ type: 'missing/type' })
|
||||
expect(result[0].properties).toEqual({ nested: { value: 1 } })
|
||||
expect(result[0].properties).not.toBe(node.serialize().properties)
|
||||
expect(warn).toHaveBeenCalledWith('Failed to create node', 'missing/type')
|
||||
})
|
||||
})
|
||||
|
||||
describe('groupResolvedByOutput', () => {
|
||||
it('groups connections by subgraph input before regular output', () => {
|
||||
const subgraph = createTestSubgraph({ inputCount: 1 })
|
||||
const output = { name: 'out' }
|
||||
const first = {
|
||||
subgraphInput: subgraph.inputs[0],
|
||||
output,
|
||||
link: new LLink(toLinkId(1), 'STRING', 1, 0, 2, 0)
|
||||
} as ResolvedConnection
|
||||
const second = {
|
||||
subgraphInput: subgraph.inputs[0],
|
||||
link: new LLink(toLinkId(2), 'STRING', 1, 0, 3, 0)
|
||||
} as ResolvedConnection
|
||||
|
||||
const result = groupResolvedByOutput([first, second])
|
||||
|
||||
expect(result.get(subgraph.inputs[0])).toEqual([first, second])
|
||||
expect(result.has(output)).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps unresolved output connections in separate groups', () => {
|
||||
const first = {
|
||||
link: new LLink(toLinkId(1), 'STRING', 1, 0, 2, 0)
|
||||
} as ResolvedConnection
|
||||
const second = {
|
||||
link: new LLink(toLinkId(2), 'STRING', 1, 0, 3, 0)
|
||||
} as ResolvedConnection
|
||||
|
||||
const result = groupResolvedByOutput([first, second])
|
||||
|
||||
expect(result.size).toBe(2)
|
||||
expect([...result.values()]).toEqual([[first], [second]])
|
||||
})
|
||||
})
|
||||
|
||||
describe('mapSubgraphInputsAndLinks', () => {
|
||||
it('creates unique input metadata and rewrites link origins', () => {
|
||||
const targetInput = makeNode('Target').inputs[0]
|
||||
targetInput.localized_name = 'Prompt'
|
||||
targetInput.label = 'Prompt label'
|
||||
const link = new LLink(toLinkId(1), 'STRING', 10, 0, 20, 0)
|
||||
const connection = {
|
||||
link,
|
||||
input: targetInput
|
||||
} as ResolvedConnection
|
||||
const links: SerialisableLLink[] = []
|
||||
|
||||
const inputs = mapSubgraphInputsAndLinks([connection], links, new Map())
|
||||
|
||||
expect(inputs).toHaveLength(1)
|
||||
expect(inputs[0]).toMatchObject({
|
||||
name: 'in',
|
||||
localized_name: 'Prompt',
|
||||
label: 'Prompt label',
|
||||
type: 'STRING',
|
||||
linkIds: [toLinkId(1)]
|
||||
})
|
||||
expect(links[0]).toMatchObject({
|
||||
origin_id: '-10',
|
||||
origin_slot: 0,
|
||||
target_id: 20,
|
||||
target_slot: 0
|
||||
})
|
||||
})
|
||||
|
||||
it('restores the original link parent while mapping reroutes', () => {
|
||||
const targetInput = makeNode('Target').inputs[0]
|
||||
const link = new LLink(
|
||||
toLinkId(1),
|
||||
'STRING',
|
||||
10,
|
||||
0,
|
||||
20,
|
||||
0,
|
||||
toRerouteId(2)
|
||||
)
|
||||
const first = new Reroute(
|
||||
toRerouteId(1),
|
||||
new LGraph(),
|
||||
undefined,
|
||||
toRerouteId(99)
|
||||
)
|
||||
const second = new Reroute(
|
||||
toRerouteId(2),
|
||||
new LGraph(),
|
||||
undefined,
|
||||
first.id
|
||||
)
|
||||
const links: SerialisableLLink[] = []
|
||||
|
||||
mapSubgraphInputsAndLinks(
|
||||
[{ link, input: targetInput } as ResolvedConnection],
|
||||
links,
|
||||
new Map([
|
||||
[first.id, first],
|
||||
[second.id, second]
|
||||
])
|
||||
)
|
||||
|
||||
expect(link.parentId).toBe(toRerouteId(99))
|
||||
expect(links[0].parentId).toBe(second.id)
|
||||
expect(first.parentId).toBeUndefined()
|
||||
expect(second.parentId).toBe(first.id)
|
||||
})
|
||||
|
||||
it('skips unresolved input connections and uniquifies duplicate names', () => {
|
||||
const firstInput = makeNode('First').inputs[0]
|
||||
firstInput.localized_name = 'Prompt'
|
||||
const secondInput = makeNode('Second').inputs[0]
|
||||
secondInput.localized_name = 'Prompt'
|
||||
const links: SerialisableLLink[] = []
|
||||
|
||||
const inputs = mapSubgraphInputsAndLinks(
|
||||
[
|
||||
{ link: new LLink(toLinkId(1), 'STRING', 1, 0, 2, 0) },
|
||||
{
|
||||
link: new LLink(toLinkId(2), 'STRING', 1, 0, 3, 0),
|
||||
input: firstInput
|
||||
},
|
||||
{
|
||||
link: new LLink(toLinkId(3), 'STRING', 1, 0, 4, 0),
|
||||
input: secondInput
|
||||
}
|
||||
] as ResolvedConnection[],
|
||||
links,
|
||||
new Map()
|
||||
)
|
||||
|
||||
expect(inputs.map((input) => input.name)).toEqual(['in', 'in_1'])
|
||||
expect(inputs.map((input) => input.localized_name)).toEqual([
|
||||
'Prompt',
|
||||
'Prompt_1'
|
||||
])
|
||||
expect(links.map((link) => link.id)).toEqual([toLinkId(2), toLinkId(3)])
|
||||
})
|
||||
})
|
||||
|
||||
describe('mapSubgraphOutputsAndLinks', () => {
|
||||
it('creates unique output metadata and rewrites link targets', () => {
|
||||
const output = makeNode('Source').outputs[0]
|
||||
output.type = 'IMAGE'
|
||||
output.localized_name = 'Image'
|
||||
output.label = 'Image label'
|
||||
const link = new LLink(toLinkId(1), 'IMAGE', 10, 0, 20, 0)
|
||||
const links: SerialisableLLink[] = []
|
||||
|
||||
const outputs = mapSubgraphOutputsAndLinks(
|
||||
[{ link, output } as ResolvedConnection],
|
||||
links,
|
||||
new Map()
|
||||
)
|
||||
|
||||
expect(outputs).toHaveLength(1)
|
||||
expect(outputs[0]).toMatchObject({
|
||||
name: 'out',
|
||||
localized_name: 'Image',
|
||||
label: 'Image label',
|
||||
type: 'IMAGE',
|
||||
linkIds: [toLinkId(1)]
|
||||
})
|
||||
expect(links[0]).toMatchObject({
|
||||
origin_id: 10,
|
||||
origin_slot: 0,
|
||||
target_id: '-20',
|
||||
target_slot: 0
|
||||
})
|
||||
})
|
||||
|
||||
it('skips unresolved output connections and uniquifies duplicate names', () => {
|
||||
const firstOutput = makeNode('First').outputs[0]
|
||||
firstOutput.localized_name = 'Image'
|
||||
const secondOutput = makeNode('Second').outputs[0]
|
||||
secondOutput.localized_name = 'Image'
|
||||
const links: SerialisableLLink[] = []
|
||||
|
||||
const outputs = mapSubgraphOutputsAndLinks(
|
||||
[
|
||||
{ link: new LLink(toLinkId(1), 'IMAGE', 1, 0, 2, 0) },
|
||||
{
|
||||
link: new LLink(toLinkId(2), 'IMAGE', 1, 0, 3, 0),
|
||||
output: firstOutput
|
||||
},
|
||||
{
|
||||
link: new LLink(toLinkId(3), 'IMAGE', 1, 0, 4, 0),
|
||||
output: secondOutput
|
||||
}
|
||||
] as ResolvedConnection[],
|
||||
links,
|
||||
new Map()
|
||||
)
|
||||
|
||||
expect(outputs.map((output) => output.name)).toEqual(['out', 'out_1'])
|
||||
expect(outputs.map((output) => output.localized_name)).toEqual([
|
||||
'Image',
|
||||
'Image_1'
|
||||
])
|
||||
expect(links.map((link) => link.id)).toEqual([toLinkId(2), toLinkId(3)])
|
||||
})
|
||||
})
|
||||
|
||||
describe('reorderSubgraphInputs', () => {
|
||||
it('returns when the host has no subgraph', () => {
|
||||
expect(() =>
|
||||
reorderSubgraphInputs(
|
||||
{ subgraph: null } as unknown as Parameters<
|
||||
typeof reorderSubgraphInputs
|
||||
>[0],
|
||||
[]
|
||||
)
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it('logs and leaves inputs unchanged for invalid permutations', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [
|
||||
{ name: 'first', type: 'STRING' },
|
||||
{ name: 'second', type: 'STRING' }
|
||||
]
|
||||
})
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
reorderSubgraphInputs(host, [1, 1])
|
||||
|
||||
expect(subgraph.inputs.map((input) => input.name)).toEqual([
|
||||
'first',
|
||||
'second'
|
||||
])
|
||||
expect(error).toHaveBeenCalledWith(
|
||||
'reorderSubgraphInputs: orderedIndices must be a permutation of 0..1',
|
||||
[1, 1]
|
||||
)
|
||||
})
|
||||
|
||||
it('dispatches reorder details when the input order changes', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [
|
||||
{ name: 'first', type: 'STRING' },
|
||||
{ name: 'second', type: 'STRING' }
|
||||
]
|
||||
})
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const dispatch = vi.spyOn(subgraph.events, 'dispatch')
|
||||
|
||||
reorderSubgraphInputs(host, [1, 0])
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith('inputs-reordered', {
|
||||
subgraph,
|
||||
oldOrder: expect.any(Array),
|
||||
newOrder: expect.any(Array)
|
||||
})
|
||||
expect(subgraph.inputs.map((input) => input.name)).toEqual([
|
||||
'second',
|
||||
'first'
|
||||
])
|
||||
})
|
||||
|
||||
it('does not dispatch when the input order is unchanged', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [
|
||||
{ name: 'first', type: 'STRING' },
|
||||
{ name: 'second', type: 'STRING' }
|
||||
]
|
||||
})
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
subgraph.inputs[0].linkIds.push(toLinkId(404))
|
||||
host.inputs[0].link = toLinkId(405)
|
||||
const dispatch = vi.spyOn(subgraph.events, 'dispatch')
|
||||
|
||||
reorderSubgraphInputs(host, [0, 1])
|
||||
|
||||
expect(dispatch).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('slot type guards', () => {
|
||||
it('identifies subgraph slots and node slots', () => {
|
||||
const subgraph = createTestSubgraph({ inputCount: 1, outputCount: 1 })
|
||||
const node = makeNode('Node')
|
||||
|
||||
expect(isSubgraphInput(subgraph.inputs[0])).toBe(true)
|
||||
expect(isSubgraphInput(subgraph.outputs[0])).toBe(false)
|
||||
expect(isSubgraphOutput(subgraph.outputs[0])).toBe(true)
|
||||
expect(isSubgraphOutput(node.outputs[0])).toBe(false)
|
||||
expect(isNodeSlot(node.inputs[0])).toBe(true)
|
||||
expect(isNodeSlot(node.outputs[0])).toBe(true)
|
||||
expect(isNodeSlot(null)).toBe(false)
|
||||
expect(isNodeSlot({})).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '../LGraphNode'
|
||||
import { alignNodes, distributeNodes, getBoundaryNodes } from './arrange'
|
||||
|
||||
type ArrangeNode = LGraphNode & { title: string }
|
||||
|
||||
function nodeFixture(
|
||||
title: string,
|
||||
pos: [number, number],
|
||||
size: [number, number]
|
||||
): ArrangeNode {
|
||||
const graphNode = {
|
||||
title,
|
||||
pos,
|
||||
size,
|
||||
setPos: vi.fn((x: number, y: number) => {
|
||||
graphNode.pos = [x, y]
|
||||
})
|
||||
}
|
||||
return graphNode as unknown as ArrangeNode
|
||||
}
|
||||
|
||||
describe('arrange utilities', () => {
|
||||
it('returns null when no boundary node is available', () => {
|
||||
expect(getBoundaryNodes([])).toBeNull()
|
||||
expect(getBoundaryNodes(undefined as unknown as LGraphNode[])).toBeNull()
|
||||
})
|
||||
|
||||
it('finds the furthest node in each direction', () => {
|
||||
const top = nodeFixture('top', [10, -10], [20, 20])
|
||||
const right = nodeFixture('right', [100, 0], [50, 20])
|
||||
const bottom = nodeFixture('bottom', [0, 80], [20, 60])
|
||||
const left = nodeFixture('left', [-20, 0], [10, 10])
|
||||
|
||||
expect(getBoundaryNodes([top, right, bottom, left])).toEqual({
|
||||
top,
|
||||
right,
|
||||
bottom,
|
||||
left
|
||||
})
|
||||
})
|
||||
|
||||
it('does not distribute zero or one node', () => {
|
||||
expect(distributeNodes([])).toEqual([])
|
||||
expect(distributeNodes([nodeFixture('single', [0, 0], [10, 10])])).toEqual(
|
||||
[]
|
||||
)
|
||||
})
|
||||
|
||||
it('distributes nodes horizontally by sorted position', () => {
|
||||
const first = nodeFixture('first', [0, 10], [10, 10])
|
||||
const middle = nodeFixture('middle', [30, 20], [10, 10])
|
||||
const last = nodeFixture('last', [60, 30], [20, 10])
|
||||
|
||||
const result = distributeNodes([last, first, middle], true)
|
||||
|
||||
expect(result.map(({ node: resultNode }) => resultNode.title)).toEqual([
|
||||
'first',
|
||||
'middle',
|
||||
'last'
|
||||
])
|
||||
expect(first.pos).toEqual([0, 10])
|
||||
expect(middle.pos).toEqual([30, 20])
|
||||
expect(last.pos).toEqual([60, 30])
|
||||
})
|
||||
|
||||
it('distributes nodes vertically by sorted position', () => {
|
||||
const first = nodeFixture('first', [10, 0], [10, 10])
|
||||
const middle = nodeFixture('middle', [20, 30], [10, 10])
|
||||
const last = nodeFixture('last', [30, 60], [10, 20])
|
||||
|
||||
distributeNodes([last, first, middle])
|
||||
|
||||
expect(first.pos).toEqual([10, 0])
|
||||
expect(middle.pos).toEqual([20, 30])
|
||||
expect(last.pos).toEqual([30, 60])
|
||||
})
|
||||
|
||||
it('aligns nodes to each boundary edge', () => {
|
||||
const nodesForAlign = () => [
|
||||
nodeFixture('top', [10, 0], [10, 10]),
|
||||
nodeFixture('right', [40, 10], [30, 10]),
|
||||
nodeFixture('bottom', [20, 50], [10, 30]),
|
||||
nodeFixture('left', [-10, 20], [10, 10])
|
||||
]
|
||||
|
||||
expect(
|
||||
alignNodes(nodesForAlign(), 'left').map(({ newPos }) => newPos.x)
|
||||
).toEqual([-10, -10, -10, -10])
|
||||
expect(
|
||||
alignNodes(nodesForAlign(), 'right').map(({ newPos }) => newPos.x)
|
||||
).toEqual([60, 40, 60, 60])
|
||||
expect(
|
||||
alignNodes(nodesForAlign(), 'top').map(({ newPos }) => newPos.y)
|
||||
).toEqual([0, 0, 0, 0])
|
||||
expect(
|
||||
alignNodes(nodesForAlign(), 'bottom').map(({ newPos }) => newPos.y)
|
||||
).toEqual([70, 70, 50, 70])
|
||||
})
|
||||
|
||||
it('aligns to an explicit node when provided', () => {
|
||||
const anchor = nodeFixture('anchor', [100, 200], [50, 60])
|
||||
const target = nodeFixture('target', [0, 0], [10, 20])
|
||||
|
||||
const result = alignNodes([target], 'bottom', anchor)
|
||||
|
||||
expect(result[0].newPos).toEqual({ x: 0, y: 240 })
|
||||
expect(target.setPos).toHaveBeenCalledWith(0, 240)
|
||||
})
|
||||
|
||||
it('returns no positions when alignment has no usable nodes', () => {
|
||||
expect(alignNodes([], 'left')).toEqual([])
|
||||
expect(alignNodes(undefined as unknown as LGraphNode[], 'left')).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,119 +0,0 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
|
||||
import {
|
||||
findFirstNode,
|
||||
findFreeSlotOfType,
|
||||
getAllNestedItems
|
||||
} from './collections'
|
||||
|
||||
import type { Positionable } from '../interfaces'
|
||||
|
||||
const graphNodeMock = vi.hoisted(() => ({
|
||||
LGraphNode: class TestLGraphNode {
|
||||
constructor(readonly title: string) {}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/litegraph/src/LGraphNode', () => graphNodeMock)
|
||||
|
||||
describe('getAllNestedItems', () => {
|
||||
it('returns empty for an undefined input set', () => {
|
||||
expect(
|
||||
getAllNestedItems(undefined as unknown as ReadonlySet<Positionable>)
|
||||
).toEqual(new Set())
|
||||
})
|
||||
|
||||
it('flattens nested children while skipping pinned and repeated items', () => {
|
||||
const leaf = fromPartial<Positionable>({ pinned: false })
|
||||
const hiddenChild = fromPartial<Positionable>({ pinned: false })
|
||||
const pinned = fromPartial<Positionable>({
|
||||
pinned: true,
|
||||
children: new Set([leaf, hiddenChild])
|
||||
})
|
||||
const parent = fromPartial<Positionable>({
|
||||
pinned: false,
|
||||
children: new Set([leaf, pinned])
|
||||
})
|
||||
|
||||
const result = getAllNestedItems(new Set([parent, leaf]))
|
||||
|
||||
expect(result).toEqual(new Set([parent, leaf]))
|
||||
expect(result.has(hiddenChild)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('findFirstNode', () => {
|
||||
it('returns the first graph node from a mixed collection', () => {
|
||||
const node = new LGraphNode('node')
|
||||
|
||||
expect(findFirstNode([{ pinned: false } as Positionable, node])).toBe(node)
|
||||
})
|
||||
|
||||
it('returns undefined when no graph node is present', () => {
|
||||
expect(findFirstNode([{ pinned: false } as Positionable])).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('findFreeSlotOfType', () => {
|
||||
interface Slot {
|
||||
type: string
|
||||
links: number[]
|
||||
}
|
||||
|
||||
const hasNoLinks = (slot: Slot) => slot.links.length === 0
|
||||
|
||||
it('returns undefined for an empty slot list', () => {
|
||||
expect(findFreeSlotOfType([], 'IMAGE', hasNoLinks)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('prefers the first free exact type match', () => {
|
||||
const slots = [
|
||||
{ type: 'IMAGE', links: [1] },
|
||||
{ type: 'IMAGE', links: [] }
|
||||
]
|
||||
|
||||
expect(findFreeSlotOfType(slots, 'IMAGE', hasNoLinks)).toEqual({
|
||||
index: 1,
|
||||
slot: slots[1]
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to a free wildcard before an occupied exact slot', () => {
|
||||
const slots = [
|
||||
{ type: 'IMAGE', links: [1] },
|
||||
{ type: '*', links: [] }
|
||||
]
|
||||
|
||||
expect(findFreeSlotOfType(slots, 'IMAGE', hasNoLinks)).toEqual({
|
||||
index: 1,
|
||||
slot: slots[1]
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to an occupied exact slot before an occupied wildcard', () => {
|
||||
const slots = [
|
||||
{ type: '*', links: [1] },
|
||||
{ type: 'IMAGE', links: [2] }
|
||||
]
|
||||
|
||||
expect(findFreeSlotOfType(slots, 'IMAGE', hasNoLinks)).toEqual({
|
||||
index: 1,
|
||||
slot: slots[1]
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to an occupied wildcard when no exact slot matches', () => {
|
||||
const slots = [
|
||||
{ type: 'LATENT', links: [1] },
|
||||
{ type: '*', links: [2] }
|
||||
]
|
||||
|
||||
expect(findFreeSlotOfType(slots, 'IMAGE', hasNoLinks)).toEqual({
|
||||
index: 1,
|
||||
slot: slots[1]
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -3690,13 +3690,13 @@
|
||||
},
|
||||
"linearMode": {
|
||||
"linearMode": "App Mode",
|
||||
"beta": "App mode in beta",
|
||||
"buildAnApp": "Build an app",
|
||||
"giveFeedback": "Give feedback",
|
||||
"graphMode": "Graph Mode",
|
||||
"dragAndDropImage": "Click to browse or drag an image",
|
||||
"mobileControls": "Edit & Run",
|
||||
"runCount": "Number of runs",
|
||||
"runCount": "Generations",
|
||||
"generating": "Generating…",
|
||||
"stopGeneration": "Stop generation",
|
||||
"rerun": "Rerun",
|
||||
"reuseParameters": "Reuse Parameters",
|
||||
"downloadAll": "Download {count} assets from this run",
|
||||
@@ -3705,26 +3705,41 @@
|
||||
"emptyWorkflowExplanation": "Your workflow is empty. You need some nodes first to start building an app.",
|
||||
"backToWorkflow": "Back to workflow",
|
||||
"loadTemplate": "Load a template",
|
||||
"cancelThisRun": "Cancel this run",
|
||||
"deleteAllAssets": "Delete all assets from this run",
|
||||
"hasCreditCost": "Requires additional credits",
|
||||
"creditApproximateInfo": "Credit consumption shown here is approximate due to 3rd party provider calculations, exact values are available in your job history once the workflow is executed",
|
||||
"creditBreakdown": "Credit breakdown by model",
|
||||
"usesCredits": "Uses credits",
|
||||
"viewGraph": "View node graph",
|
||||
"mobileNoWorkflow": "This workflow hasn't been built for app mode. Try a different one.",
|
||||
"welcome": {
|
||||
"title": "App Mode",
|
||||
"message": "A simplified view that hides the node graph so you can focus on creating.",
|
||||
"startTour": "Take a tour of App Mode",
|
||||
"controls": "Your outputs appear at the bottom, your controls are on the right. Everything else stays out of the way.",
|
||||
"sharing": "Share your workflow as a simple tool anyone can use. Export it from the tab menu and when others open it, they'll see App Mode. No node graph knowledge needed.",
|
||||
"getStarted": "Click {runButton} to get started.",
|
||||
"buildApp": "Build app",
|
||||
"noOutputs": "An app needs at least {count} to be usable.",
|
||||
"oneOutput": "1 output"
|
||||
"getStarted": "Click {runButton} to get started."
|
||||
},
|
||||
"getStarted": {
|
||||
"title": "Get started with Apps",
|
||||
"subtitle": "Pick an app template to get started. Each one is built on a workflow.",
|
||||
"templates": "Templates",
|
||||
"importWorkflow": "Import workflow",
|
||||
"discoverAll": "Discover all templates"
|
||||
},
|
||||
"buildPrompt": {
|
||||
"title": "Make this workflow an App",
|
||||
"description": "Pick which nodes become inputs and outputs, and we'll generate a simple form anyone can run.",
|
||||
"button": "Build your App"
|
||||
},
|
||||
"appModeToolbar": {
|
||||
"appBuilder": "App builder",
|
||||
"apps": "Apps",
|
||||
"appsEmptyMessage": "Saved apps will show up here.",
|
||||
"appsEmptyMessageAction": "Click below to build your first app."
|
||||
"appsEmptyMessageAction": "Click below to build your first app.",
|
||||
"buildAnApp": "Build an app",
|
||||
"create": "Create",
|
||||
"createApp": "Create app"
|
||||
},
|
||||
"arrange": {
|
||||
"noOutputs": "No outputs added yet",
|
||||
@@ -3767,6 +3782,7 @@
|
||||
"support": "contact our support",
|
||||
"promptShow": "Show error report"
|
||||
},
|
||||
"feedbackLoadError": "Failed to load feedback form. Please try again later.",
|
||||
"queue": {
|
||||
"clickToClear": "Click to clear queue",
|
||||
"clear": "Clear queue"
|
||||
@@ -4489,5 +4505,39 @@
|
||||
"training": "Training…",
|
||||
"processingVideo": "Processing video…",
|
||||
"running": "Running…"
|
||||
},
|
||||
"onboardingCoachmarks": {
|
||||
"stepLabel": "Step {current} of {total}",
|
||||
"skip": "Skip",
|
||||
"next": "Next",
|
||||
"done": "Done",
|
||||
"appMode": {
|
||||
"landing": {
|
||||
"title": "Welcome to Apps",
|
||||
"body": "A quick tour of the essentials, in about a minute. You'll fill inputs and see your first generation.",
|
||||
"primary": "Start tutorial",
|
||||
"skip": "Skip for now"
|
||||
},
|
||||
"inputs": {
|
||||
"title": "Add your inputs",
|
||||
"body": "Add what you want to work with. Your inputs are what the app turns into results."
|
||||
},
|
||||
"run": {
|
||||
"title": "Run your app",
|
||||
"body": "Happy with your inputs? Hit Run and your result appears in the center the moment it's ready."
|
||||
},
|
||||
"outputs": {
|
||||
"title": "Get your results",
|
||||
"body": "Your finished results show up here in the center. Download them, or tweak an input and run again."
|
||||
},
|
||||
"assetsButton": {
|
||||
"title": "Open your assets",
|
||||
"body": "Click the Assets button in the left toolbar to open your media library."
|
||||
},
|
||||
"assets": {
|
||||
"title": "Find all your assets",
|
||||
"body": "Every generation and import lives in Media Assets. Open it anytime to browse, download, or reuse past work."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import { CREDITS_ICON } from '@/base/credits/comfyCredits'
|
||||
import Slider from '@/components/ui/slider/Slider.vue'
|
||||
import {
|
||||
DEFAULT_TEAM_PLAN_STOP_INDEX,
|
||||
@@ -215,7 +216,8 @@ const { t } = useI18n()
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
'icon-[comfy--credits] size-3 shrink-0',
|
||||
CREDITS_ICON,
|
||||
'size-3 shrink-0',
|
||||
i === selectedIndex ? 'bg-amber-400' : 'bg-muted-foreground'
|
||||
)
|
||||
"
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
</div>
|
||||
<Skeleton v-if="isLoadingBalance" width="8rem" height="2rem" />
|
||||
<div v-else class="flex items-baseline gap-2">
|
||||
<i class="icon-[lucide--component] size-4 self-center text-credit" />
|
||||
<i :class="cn(CREDITS_ICON, 'size-4 self-center text-credit')" />
|
||||
<span class="text-2xl leading-none font-bold">{{ displayTotal }}</span>
|
||||
<span class="text-sm text-muted @max-[300px]:hidden">{{
|
||||
$t('subscription.remaining')
|
||||
@@ -81,7 +81,7 @@
|
||||
v-else
|
||||
class="flex items-center gap-1 font-bold text-text-primary"
|
||||
>
|
||||
<i class="icon-[lucide--component] size-4 text-credit" />
|
||||
<i :class="cn(CREDITS_ICON, 'size-4 text-credit')" />
|
||||
<span class="@max-[180px]:hidden">
|
||||
{{
|
||||
$t('subscription.creditsLeftOfTotal', {
|
||||
@@ -133,7 +133,7 @@
|
||||
v-else
|
||||
class="flex items-center gap-1 font-bold text-text-primary"
|
||||
>
|
||||
<i class="icon-[lucide--component] size-4 text-credit" />
|
||||
<i :class="cn(CREDITS_ICON, 'size-4 text-credit')" />
|
||||
{{ displayPrepaid }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -179,7 +179,7 @@ import Skeleton from 'primevue/skeleton'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { formatCredits } from '@/base/credits/comfyCredits'
|
||||
import { CREDITS_ICON, formatCredits } from '@/base/credits/comfyCredits'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
|
||||
@@ -123,7 +123,7 @@
|
||||
</span>
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
<i
|
||||
class="icon-[comfy--credits] size-4 shrink-0 bg-amber-400"
|
||||
:class="cn(CREDITS_ICON, 'size-4 shrink-0 bg-amber-400')"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span
|
||||
@@ -264,6 +264,7 @@ import type { ToggleButtonPassThroughMethodOptions } from 'primevue/togglebutton
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { CREDITS_ICON } from '@/base/credits/comfyCredits'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useAuthActions } from '@/composables/auth/useAuthActions'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
|
||||
@@ -4,14 +4,20 @@
|
||||
value: buttonTooltip,
|
||||
showDelay: 600
|
||||
}"
|
||||
class="subscribe-to-run-button h-8 gap-1.5 rounded-lg px-4 whitespace-nowrap"
|
||||
variant="gradient"
|
||||
:class="
|
||||
cn(
|
||||
'subscribe-to-run-button gap-1.5 rounded-lg px-4 whitespace-nowrap [--credits-pill-base:var(--color-brand-yellow)]',
|
||||
large ? 'h-10 text-sm' : 'h-8'
|
||||
)
|
||||
"
|
||||
variant="brand-yellow"
|
||||
size="unset"
|
||||
data-testid="subscribe-to-run-button"
|
||||
@click="handleSubscribeToRun"
|
||||
>
|
||||
<i class="pi pi-lock" />
|
||||
{{ buttonLabel }}
|
||||
<slot name="trailing" />
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
@@ -25,8 +31,12 @@ import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useRunButtonTelemetry } from '@/composables/useRunButtonTelemetry'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { large = false } = defineProps<{ large?: boolean }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const breakpoints = useBreakpoints(breakpointsTailwind)
|
||||
const isMdOrLarger = breakpoints.greaterOrEqual('md')
|
||||
|
||||
@@ -43,11 +53,13 @@ const buttonLabel = computed(() => {
|
||||
: t('subscription.subscribeToRun')
|
||||
})
|
||||
|
||||
const buttonTooltip = computed(() =>
|
||||
canResubscribe.value
|
||||
const buttonTooltip = computed(() => {
|
||||
const tooltip = canResubscribe.value
|
||||
? t('subscription.subscribeToRunFull')
|
||||
: t('subscription.inactive.memberRunTooltip')
|
||||
)
|
||||
// Skip the tooltip when it would only repeat the visible button label
|
||||
return tooltip === buttonLabel.value ? undefined : tooltip
|
||||
})
|
||||
|
||||
function handleSubscribeToRun() {
|
||||
if (isCloud) {
|
||||
|
||||
67
src/platform/onboarding/CoachmarkCard.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { cleanup, render, screen } from '@testing-library/vue'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { h } from 'vue'
|
||||
|
||||
import CoachmarkCard from './CoachmarkCard.vue'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('CoachmarkCard', () => {
|
||||
it('renders the title, message and subtitle', () => {
|
||||
render(CoachmarkCard, {
|
||||
props: {
|
||||
title: 'This is your canvas',
|
||||
message: 'Scroll to zoom.',
|
||||
subtitle: 'Step 1 of 3'
|
||||
}
|
||||
})
|
||||
expect(screen.getByRole('heading')).toHaveTextContent('This is your canvas')
|
||||
expect(screen.getByText('Scroll to zoom.')).toBeTruthy()
|
||||
expect(screen.getByText('Step 1 of 3')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('applies titleId to the heading for aria-labelledby wiring', () => {
|
||||
render(CoachmarkCard, {
|
||||
props: { title: 'Heading', message: 'M', titleId: 'title-1' }
|
||||
})
|
||||
expect(screen.getByRole('heading').id).toBe('title-1')
|
||||
})
|
||||
|
||||
it('applies messageId to the message for aria-describedby wiring', () => {
|
||||
render(CoachmarkCard, {
|
||||
props: { title: 'T', message: 'Body copy', messageId: 'desc-1' }
|
||||
})
|
||||
expect(screen.getByText('Body copy').id).toBe('desc-1')
|
||||
})
|
||||
|
||||
it('omits the subtitle when not provided', () => {
|
||||
render(CoachmarkCard, { props: { title: 'T', message: 'M' } })
|
||||
expect(screen.queryByText('Step 1 of 3')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders the image when an image src is given', () => {
|
||||
render(CoachmarkCard, {
|
||||
props: { title: 'T', message: 'M', image: '/foo.png' }
|
||||
})
|
||||
expect(screen.getByAltText('')).toHaveAttribute('src', '/foo.png')
|
||||
})
|
||||
|
||||
it('renders an image slot in place of the default image', () => {
|
||||
render(CoachmarkCard, {
|
||||
props: { title: 'T', message: 'M' },
|
||||
slots: { image: () => h('img', { src: '/slot.png', alt: 'preview' }) }
|
||||
})
|
||||
expect(screen.getByRole('img', { name: 'preview' })).toHaveAttribute(
|
||||
'src',
|
||||
'/slot.png'
|
||||
)
|
||||
})
|
||||
|
||||
it('renders the actions slot', () => {
|
||||
render(CoachmarkCard, {
|
||||
props: { title: 'T', message: 'M' },
|
||||
slots: { actions: () => h('button', 'Next') }
|
||||
})
|
||||
expect(screen.getByRole('button', { name: 'Next' })).toBeTruthy()
|
||||
})
|
||||
})
|
||||
50
src/platform/onboarding/CoachmarkCard.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex w-full flex-col items-start justify-center gap-3 rounded-2xl bg-secondary-background p-4 drop-shadow-[1px_1px_8px_rgba(0,0,0,0.4)]"
|
||||
>
|
||||
<div
|
||||
v-if="image || $slots.image"
|
||||
class="flex h-[146px] flex-col items-start justify-center gap-4 self-stretch overflow-hidden rounded-xl bg-base-background"
|
||||
>
|
||||
<slot name="image">
|
||||
<img v-if="image" :src="image" alt="" class="size-full object-cover" />
|
||||
</slot>
|
||||
</div>
|
||||
<div class="flex flex-col items-end justify-end gap-6 self-stretch">
|
||||
<div class="flex flex-col items-start gap-2 self-stretch">
|
||||
<p
|
||||
v-if="subtitle"
|
||||
:id="subtitleId"
|
||||
class="m-0 text-xs/normal text-base-foreground"
|
||||
>
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
<h3
|
||||
:id="titleId"
|
||||
class="m-0 text-base/normal font-semibold text-base-foreground"
|
||||
>
|
||||
{{ title }}
|
||||
</h3>
|
||||
<p :id="messageId" class="m-0 text-sm/normal text-muted-foreground">
|
||||
{{ message }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="$slots.actions" class="flex items-center gap-3">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { title, titleId, message, subtitle, subtitleId, image, messageId } =
|
||||
defineProps<{
|
||||
title: string
|
||||
titleId?: string
|
||||
message: string
|
||||
subtitle?: string
|
||||
subtitleId?: string
|
||||
image?: string
|
||||
messageId?: string
|
||||
}>()
|
||||
</script>
|
||||
61
src/platform/onboarding/CoachmarkLanding.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { cleanup, render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import CoachmarkLanding from './CoachmarkLanding.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: { g: { close: 'Close' } } }
|
||||
})
|
||||
|
||||
function renderLanding() {
|
||||
return render(CoachmarkLanding, {
|
||||
props: {
|
||||
title: 'Welcome to Apps',
|
||||
message: 'A quick tour of the essentials.',
|
||||
primaryLabel: 'Start tutorial',
|
||||
skipLabel: 'Skip for now'
|
||||
},
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
}
|
||||
|
||||
describe('CoachmarkLanding', () => {
|
||||
afterEach(cleanup)
|
||||
|
||||
it('renders the title and message', async () => {
|
||||
renderLanding()
|
||||
expect(await screen.findByText('Welcome to Apps')).toBeTruthy()
|
||||
expect(screen.getByText('A quick tour of the essentials.')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('emits start when the primary action is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { emitted } = renderLanding()
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: 'Start tutorial' })
|
||||
)
|
||||
expect(emitted().start).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('emits skip when Skip is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { emitted } = renderLanding()
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: 'Skip for now' })
|
||||
)
|
||||
expect(emitted().skip).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('emits skip when Escape is pressed', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { emitted } = renderLanding()
|
||||
await screen.findByText('Welcome to Apps')
|
||||
await user.keyboard('{Escape}')
|
||||
// The explicit listener and Reka's own dismiss may both fire here.
|
||||
expect(emitted().skip?.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
})
|
||||