Compare commits
14 Commits
codex/cove
...
lint/test-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad3686631c | ||
|
|
ffc92d3d42 | ||
|
|
1ab34ae4de | ||
|
|
156f2f59b7 | ||
|
|
d855466fdf | ||
|
|
9d5719871a | ||
|
|
7610a61250 | ||
|
|
47c8b09ebf | ||
|
|
65b4c53bcb | ||
|
|
15b31d69ea | ||
|
|
471236e08d | ||
|
|
4cc0402325 | ||
|
|
a2adfe5124 | ||
|
|
49a90d4e2e |
2
.github/workflows/ci-tests-e2e-coverage.yaml
vendored
@@ -121,7 +121,7 @@ jobs:
|
||||
--title "ComfyUI E2E Coverage" \
|
||||
--no-function-coverage \
|
||||
--precision 1 \
|
||||
--ignore-errors source,unmapped,range \
|
||||
--ignore-errors source,unmapped \
|
||||
--synthesize-missing
|
||||
|
||||
- name: Upload HTML report artifact
|
||||
|
||||
1
.github/workflows/ci-tests-storybook.yaml
vendored
@@ -95,6 +95,7 @@ jobs:
|
||||
if: |
|
||||
github.event_name == 'workflow_dispatch'
|
||||
|| (github.event_name == 'pull_request'
|
||||
&& github.event.pull_request.head.repo.fork == false
|
||||
&& startsWith(github.head_ref, 'version-bump-')
|
||||
&& (needs.changes.outputs.storybook-changes == 'true'
|
||||
|| needs.changes.outputs.app-frontend-changes == 'true'
|
||||
|
||||
@@ -30,7 +30,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
deploy-preview:
|
||||
if: github.event_name == 'pull_request'
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
10
.github/workflows/ci-website-e2e.yaml
vendored
@@ -67,7 +67,15 @@ jobs:
|
||||
|
||||
- name: Deploy report to Cloudflare
|
||||
id: deploy
|
||||
if: always() && !cancelled()
|
||||
if: >-
|
||||
${{
|
||||
always() &&
|
||||
!cancelled() &&
|
||||
(
|
||||
github.event_name != 'pull_request' ||
|
||||
github.event.pull_request.head.repo.fork == false
|
||||
)
|
||||
}}
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
|
||||
13
.github/workflows/cloud-dispatch-build.yaml
vendored
@@ -32,12 +32,13 @@ jobs:
|
||||
if: >
|
||||
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
|
||||
(github.event_name != 'pull_request' ||
|
||||
(github.event.action == 'labeled' &&
|
||||
contains(fromJSON('["preview","preview-cpu","preview-gpu"]'), github.event.label.name)) ||
|
||||
(github.event.action == 'synchronize' &&
|
||||
(contains(github.event.pull_request.labels.*.name, 'preview') ||
|
||||
contains(github.event.pull_request.labels.*.name, 'preview-cpu') ||
|
||||
contains(github.event.pull_request.labels.*.name, 'preview-gpu'))))
|
||||
(github.event.pull_request.head.repo.fork == false &&
|
||||
((github.event.action == 'labeled' &&
|
||||
contains(fromJSON('["preview","preview-cpu","preview-gpu"]'), github.event.label.name)) ||
|
||||
(github.event.action == 'synchronize' &&
|
||||
(contains(github.event.pull_request.labels.*.name, 'preview') ||
|
||||
contains(github.event.pull_request.labels.*.name, 'preview-cpu') ||
|
||||
contains(github.event.pull_request.labels.*.name, 'preview-gpu'))))))
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Build client payload
|
||||
|
||||
@@ -21,6 +21,7 @@ jobs:
|
||||
# - Preview label specifically removed
|
||||
if: >
|
||||
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
|
||||
github.event.pull_request.head.repo.fork == false &&
|
||||
((github.event.action == 'closed' &&
|
||||
(contains(github.event.pull_request.labels.*.name, 'preview') ||
|
||||
contains(github.event.pull_request.labels.*.name, 'preview-cpu') ||
|
||||
|
||||
@@ -56,7 +56,7 @@ const columnClass: Record<ColumnCount, string> = {
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
|
||||
<SectionHeader :label="eyebrow" align="start">
|
||||
<SectionHeader max-width="xl" :label="eyebrow" align="start">
|
||||
{{ heading }}
|
||||
<template v-if="subtitle" #subtitle>
|
||||
<p class="mt-4 max-w-xl text-sm text-smoke-700 lg:text-base">
|
||||
|
||||
@@ -33,36 +33,41 @@ useHeroAnimation({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section ref="sectionRef" class="px-4 py-20 lg:flex lg:px-20 lg:py-24">
|
||||
<section
|
||||
ref="sectionRef"
|
||||
class="px-4 py-20 lg:flex lg:gap-16 lg:px-20 lg:py-24"
|
||||
>
|
||||
<!-- Left column: intro + image -->
|
||||
<div class="lg:w-1/2">
|
||||
<SectionLabel ref="badgeRef">
|
||||
{{ t(tk('badge'), locale) }}
|
||||
</SectionLabel>
|
||||
<div class="lg:max-w-xl">
|
||||
<SectionLabel ref="badgeRef">
|
||||
{{ t(tk('badge'), locale) }}
|
||||
</SectionLabel>
|
||||
|
||||
<h1
|
||||
ref="headingRef"
|
||||
class="text-primary-comfy-canvas mt-4 text-3xl font-light whitespace-pre-line lg:text-5xl"
|
||||
>
|
||||
{{ t(tk('heading'), locale) }}
|
||||
</h1>
|
||||
<h1
|
||||
ref="headingRef"
|
||||
class="mt-4 text-3xl font-light whitespace-pre-line text-primary-comfy-canvas lg:text-5xl"
|
||||
>
|
||||
{{ t(tk('heading'), locale) }}
|
||||
</h1>
|
||||
|
||||
<div ref="descRef">
|
||||
<p class="text-primary-comfy-canvas mt-4 text-sm">
|
||||
{{ t(tk('description'), locale) }}
|
||||
</p>
|
||||
<div ref="descRef">
|
||||
<p class="mt-4 text-sm text-primary-comfy-canvas">
|
||||
{{ t(tk('description'), locale) }}
|
||||
</p>
|
||||
|
||||
<p class="text-primary-comfy-canvas mt-4 text-sm">
|
||||
{{ t(tk('supportLink'), locale) }}
|
||||
<a
|
||||
href="https://docs.comfy.org/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-comfy-yellow underline"
|
||||
>
|
||||
{{ t(tk('supportLinkCta'), locale) }}
|
||||
</a>
|
||||
</p>
|
||||
<p class="mt-4 text-sm text-primary-comfy-canvas">
|
||||
{{ t(tk('supportLink'), locale) }}
|
||||
<a
|
||||
href="https://docs.comfy.org/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-comfy-yellow underline"
|
||||
>
|
||||
{{ t(tk('supportLinkCta'), locale) }}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref="imageRef" class="mt-8 overflow-hidden rounded-2xl lg:-ml-20">
|
||||
|
||||
@@ -40,13 +40,13 @@ export function getMainNavigation(locale: Locale): NavItem[] {
|
||||
{
|
||||
label: t('nav.products', locale),
|
||||
featured: {
|
||||
imageSrc: 'https://media.comfy.org/website/nav/featured-model-card.jpg',
|
||||
imageSrc: 'https://media.comfy.org/website/nav/mcp-card.webp',
|
||||
imageAlt: t('nav.featuredProductsAlt', locale),
|
||||
title: t('nav.featuredProductsTitle', locale),
|
||||
cta: {
|
||||
label: t('cta.tryWorkflow', locale),
|
||||
label: t('cta.getStarted', locale),
|
||||
ariaLabel: t('nav.featuredProductsCtaAria', locale),
|
||||
href: 'https://comfy.org/workflows/api_seedance2_0_r2v-64f4db9e3e33/'
|
||||
href: routes.mcp
|
||||
}
|
||||
},
|
||||
columns: [
|
||||
|
||||
@@ -26,6 +26,10 @@ const translations = {
|
||||
en: 'Try Workflow',
|
||||
'zh-CN': '试用工作流'
|
||||
},
|
||||
'cta.getStarted': {
|
||||
en: 'GET STARTED',
|
||||
'zh-CN': '快速开始'
|
||||
},
|
||||
'cta.watchNow': {
|
||||
en: 'Watch Now',
|
||||
'zh-CN': '立即观看'
|
||||
@@ -2196,16 +2200,16 @@ const translations = {
|
||||
// Featured dropdown cards — keys are keyed by parent nav item, not card content,
|
||||
// so the copy can be swapped without renaming the key.
|
||||
'nav.featuredProductsTitle': {
|
||||
en: 'New Release: Seedance 2.0',
|
||||
'zh-CN': '全新发布:Seedance 2.0'
|
||||
en: 'NEW: COMFY MCP',
|
||||
'zh-CN': '全新发布:Comfy MCP'
|
||||
},
|
||||
'nav.featuredProductsAlt': {
|
||||
en: 'Seedance 2.0 release feature image',
|
||||
'zh-CN': 'Seedance 2.0 发布精选图片'
|
||||
en: 'Comfy MCP feature image',
|
||||
'zh-CN': 'Comfy MCP 精选图片'
|
||||
},
|
||||
'nav.featuredProductsCtaAria': {
|
||||
en: 'Try the Seedance 2.0 workflow',
|
||||
'zh-CN': '试用 Seedance 2.0 工作流'
|
||||
en: 'Get started with Comfy MCP',
|
||||
'zh-CN': '开始使用 Comfy MCP'
|
||||
},
|
||||
'nav.featuredCommunityTitle': {
|
||||
en: 'Sky Replacement',
|
||||
|
||||
@@ -537,7 +537,6 @@ export const comfyPageFixture = base.extend<{
|
||||
'Comfy.TutorialCompleted': true,
|
||||
'Comfy.Queue.MaxHistoryItems': 64,
|
||||
'Comfy.SnapToGrid.GridSize': testComfySnapToGridGridSize,
|
||||
'Comfy.VueNodes.AutoScaleLayout': false,
|
||||
// Disable toast warning about version compatibility, as they may or
|
||||
// may not appear - depending on upstream ComfyUI dependencies
|
||||
'Comfy.VersionCompatibility.DisableWarnings': true,
|
||||
|
||||
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 34 KiB |
@@ -46,6 +46,7 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
{ tag: ['@smoke', '@screenshot'] },
|
||||
async ({ comfyPage, maskEditor }) => {
|
||||
const { nodeId } = await maskEditor.loadImageOnNode()
|
||||
await comfyPage.canvasOps.pan({ x: 0, y: 40 }, { x: 300, y: 300 })
|
||||
|
||||
const nodeHeader = comfyPage.vueNodes
|
||||
.getNodeLocator(nodeId)
|
||||
|
||||
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 93 KiB |
@@ -691,7 +691,8 @@ test(
|
||||
const emptySlotPos = await seedIOSlot.getOpenSlotPosition()
|
||||
await comfyPage.canvas.hover({ position: emptySlotPos })
|
||||
await comfyPage.page.mouse.down()
|
||||
await stepsSlot.hover()
|
||||
const { width, height } = (await stepsSlot.boundingBox())!
|
||||
await stepsSlot.hover({ position: { x: (width * 3) / 4, y: height / 2 } })
|
||||
await expect.poll(hasSnap).toBe(true)
|
||||
await comfyPage.page.mouse.up()
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
@@ -1238,7 +1238,7 @@ test(
|
||||
{ tag: '@vue-nodes' },
|
||||
async ({ comfyMouse, comfyPage }) => {
|
||||
async function performDisconnect(slot: Locator, isFast: boolean) {
|
||||
await comfyMouse.dragElementBy(slot, { x: isFast ? -25 : -80 })
|
||||
await comfyMouse.dragElementBy(slot, { x: isFast ? -30 : -80 })
|
||||
|
||||
if (!isFast) {
|
||||
await expect(comfyPage.contextMenu.litegraphContextMenu).toBeVisible()
|
||||
@@ -1251,7 +1251,7 @@ test(
|
||||
|
||||
const ksamplerLocator = comfyPage.vueNodes.getNodeByTitle('KSampler')
|
||||
const ksampler = new VueNodeFixture(ksamplerLocator)
|
||||
await comfyMouse.dragElementBy(ksamplerLocator, { x: 100 })
|
||||
await comfyMouse.dragElementBy(ksampler.title, { x: 100 })
|
||||
|
||||
await test.step('Disconnection with normal links', async () => {
|
||||
await performDisconnect(ksampler.getSlot('model'), true)
|
||||
|
||||
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 60 KiB |
@@ -234,7 +234,8 @@ test.describe('Vue Node Context Menu', { tag: '@vue-nodes' }, () => {
|
||||
await comfyPage.page
|
||||
.context()
|
||||
.grantPermissions(['clipboard-read', 'clipboard-write'])
|
||||
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
await comfyPage.searchBoxV2.addNode('Load Image')
|
||||
await comfyPage.vueNodes.waitForNodes(1)
|
||||
await comfyPage.page
|
||||
.locator('[data-node-id] img')
|
||||
|
||||
@@ -14,7 +14,8 @@ const wstest = mergeTests(test, webSocketFixture)
|
||||
|
||||
test.describe('Vue Nodes Image Preview', { tag: '@vue-nodes' }, () => {
|
||||
async function loadImageOnNode(comfyPage: ComfyPage) {
|
||||
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
await comfyPage.searchBoxV2.addNode('Load Image')
|
||||
|
||||
const loadImageNode = (
|
||||
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
|
||||
|
||||
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 89 KiB |
@@ -12,14 +12,14 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
|
||||
const getHeaderPos = async (
|
||||
comfyPage: ComfyPage,
|
||||
title: string
|
||||
): Promise<{ x: number; y: number; width: number; height: number }> => {
|
||||
): Promise<{ x: number; y: number }> => {
|
||||
const box = await comfyPage.vueNodes
|
||||
.getNodeByTitle(title)
|
||||
.getByTestId('node-title')
|
||||
.first()
|
||||
.boundingBox()
|
||||
if (!box) throw new Error(`${title} header not found`)
|
||||
return box
|
||||
return { x: box.x + box.width / 2, y: box.y + box.height / 2 }
|
||||
}
|
||||
|
||||
const getLoadCheckpointHeaderPos = async (comfyPage: ComfyPage) =>
|
||||
@@ -84,29 +84,27 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
|
||||
await comfyPage.idleFrames(2)
|
||||
}
|
||||
|
||||
test('should allow moving nodes by dragging', async ({ comfyPage }) => {
|
||||
const loadCheckpointHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
|
||||
await comfyPage.canvasOps.dragAndDrop(loadCheckpointHeaderPos, {
|
||||
x: 256,
|
||||
y: 256
|
||||
})
|
||||
test('should allow moving nodes by dragging', async ({
|
||||
comfyPage,
|
||||
comfyMouse
|
||||
}) => {
|
||||
const initialHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
|
||||
const node = await comfyPage.vueNodes.getFixtureByTitle('Load Checkpoint')
|
||||
await comfyMouse.dragElementBy(node.header, { x: 100, y: 100 })
|
||||
|
||||
const newHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
|
||||
await expectPosChanged(loadCheckpointHeaderPos, newHeaderPos)
|
||||
await expectPosChanged(initialHeaderPos, newHeaderPos)
|
||||
})
|
||||
|
||||
test('should not move node when pointer moves less than drag threshold', async ({
|
||||
comfyPage
|
||||
comfyPage,
|
||||
comfyMouse
|
||||
}) => {
|
||||
const headerPos = await getLoadCheckpointHeaderPos(comfyPage)
|
||||
|
||||
// Move only 2px — below the 3px drag threshold in useNodePointerInteractions
|
||||
await comfyPage.page.mouse.move(headerPos.x, headerPos.y)
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.page.mouse.move(headerPos.x + 2, headerPos.y + 1, {
|
||||
steps: 5
|
||||
})
|
||||
await comfyPage.page.mouse.up()
|
||||
const node = await comfyPage.vueNodes.getFixtureByTitle('Load Checkpoint')
|
||||
await comfyMouse.dragElementBy(node.header, { x: 2, y: 1 })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const afterPos = await getLoadCheckpointHeaderPos(comfyPage)
|
||||
@@ -295,14 +293,12 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
|
||||
await expect(comfyPage.vueNodes.selectedNodes).toHaveCount(3)
|
||||
|
||||
// Re-fetch drag source after clicks in case the header reflowed.
|
||||
const dragSrc = await getHeaderPos(comfyPage, 'Load Checkpoint')
|
||||
const centerX = dragSrc.x + dragSrc.width / 2
|
||||
const centerY = dragSrc.y + dragSrc.height / 2
|
||||
const headerPos = await getHeaderPos(comfyPage, 'Load Checkpoint')
|
||||
|
||||
await comfyPage.page.mouse.move(centerX, centerY)
|
||||
await comfyPage.page.mouse.move(headerPos.x, headerPos.y)
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.page.mouse.move(centerX + dx, centerY + dy, {
|
||||
await comfyPage.page.mouse.move(headerPos.x + dx, headerPos.y + dy, {
|
||||
steps: 20
|
||||
})
|
||||
await comfyPage.page.mouse.up()
|
||||
|
||||
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 101 KiB |
@@ -42,7 +42,10 @@ test.describe('Vue Node Pin', { tag: '@vue-nodes' }, () => {
|
||||
await expect(pinIndicator2).toBeHidden()
|
||||
})
|
||||
|
||||
test('should not allow dragging pinned nodes', async ({ comfyPage }) => {
|
||||
test('should not allow dragging pinned nodes', async ({
|
||||
comfyMouse,
|
||||
comfyPage
|
||||
}) => {
|
||||
const checkpointNodeHeader = comfyPage.page.getByText('Load Checkpoint')
|
||||
await checkpointNodeHeader.click()
|
||||
await comfyPage.page.keyboard.press(PIN_HOTKEY)
|
||||
@@ -50,10 +53,7 @@ test.describe('Vue Node Pin', { tag: '@vue-nodes' }, () => {
|
||||
// Try to drag the node
|
||||
const headerPos = await checkpointNodeHeader.boundingBox()
|
||||
if (!headerPos) throw new Error('Failed to get header position')
|
||||
await comfyPage.canvasOps.dragAndDrop(
|
||||
{ x: headerPos.x, y: headerPos.y },
|
||||
{ x: headerPos.x + 256, y: headerPos.y + 256 }
|
||||
)
|
||||
await comfyMouse.dragElementBy(checkpointNodeHeader, { x: 256, y: 256 })
|
||||
|
||||
// Verify the node is not dragged (same position before and after click-and-drag)
|
||||
await expect
|
||||
@@ -64,11 +64,7 @@ test.describe('Vue Node Pin', { tag: '@vue-nodes' }, () => {
|
||||
await checkpointNodeHeader.click()
|
||||
await comfyPage.page.keyboard.press(PIN_HOTKEY)
|
||||
|
||||
// Try to drag the node again
|
||||
await comfyPage.canvasOps.dragAndDrop(
|
||||
{ x: headerPos.x, y: headerPos.y },
|
||||
{ x: headerPos.x + 256, y: headerPos.y + 256 }
|
||||
)
|
||||
await comfyMouse.dragElementBy(checkpointNodeHeader, { x: 256, y: 256 })
|
||||
|
||||
// Verify the node is dragged
|
||||
await expect
|
||||
|
||||
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 26 KiB |
@@ -5,12 +5,7 @@ import {
|
||||
|
||||
test.describe('Widget copy button', { tag: ['@ui', '@vue-nodes'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
// Add a PreviewAny node which has a read-only textarea with a copy button
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const node = window.LiteGraph!.createNode('PreviewAny')
|
||||
window.app!.graph.add(node)
|
||||
})
|
||||
|
||||
await comfyPage.searchBoxV2.addNode('Preview as Text')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
|
||||
@@ -336,6 +336,38 @@ export default defineConfig([
|
||||
'testing-library/no-debugging-utils': 'error'
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['**/*.test.ts'],
|
||||
// browser_tests is excluded so this warn-level entry does not override the
|
||||
// error-level no-restricted-syntax guards defined above for those paths
|
||||
ignores: ['browser_tests/**'],
|
||||
rules: {
|
||||
'no-restricted-syntax': [
|
||||
'warn',
|
||||
{
|
||||
selector: 'TSAsExpression > TSAsExpression.expression',
|
||||
message:
|
||||
'Double type assertion. Use fromPartial<T>() from @total-typescript/shoehorn instead.'
|
||||
},
|
||||
{
|
||||
selector: "ImportSpecifier[imported.name='fromAny']",
|
||||
message: 'fromAny erases type checking. Use fromPartial<T>() instead.'
|
||||
},
|
||||
{
|
||||
selector:
|
||||
"CallExpression[callee.object.name='vi'][callee.property.name='mock'] > Literal[value='vue-i18n']",
|
||||
message:
|
||||
'Do not mock vue-i18n. Use a real createI18n instance (see src/components/searchbox/v2/__test__/testUtils.ts).'
|
||||
},
|
||||
{
|
||||
selector:
|
||||
"CallExpression[callee.object.name='vi'][callee.property.name='mock'] > ImportExpression > Literal[value='vue-i18n']",
|
||||
message:
|
||||
'Do not mock vue-i18n. Use a real createI18n instance (see src/components/searchbox/v2/__test__/testUtils.ts).'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['scripts/**/*.js'],
|
||||
languageOptions: {
|
||||
|
||||
@@ -197,7 +197,7 @@
|
||||
--node-component-executing: var(--color-blue-500);
|
||||
--node-component-header: var(--fg-color);
|
||||
--node-component-header-icon: var(--color-ash-800);
|
||||
--node-component-header-surface: var(--color-smoke-400);
|
||||
--node-component-header-surface: var(--color-smoke-200);
|
||||
--node-component-outline: var(--color-black);
|
||||
--node-component-ring: rgb(from var(--color-smoke-500) r g b / 50%);
|
||||
--node-component-slot-dot-outline-opacity-mult: 1;
|
||||
@@ -343,7 +343,7 @@
|
||||
--node-component-border-executing: var(--color-blue-500);
|
||||
--node-component-border-selected: var(--color-charcoal-200);
|
||||
--node-component-header-icon: var(--color-smoke-800);
|
||||
--node-component-header-surface: var(--color-charcoal-800);
|
||||
--node-component-header-surface: var(--color-charcoal-700);
|
||||
--node-component-outline: var(--color-white);
|
||||
--node-component-ring: rgb(var(--color-smoke-500) / 20%);
|
||||
--node-component-slot-dot-outline-opacity: 10%;
|
||||
@@ -727,14 +727,14 @@ body {
|
||||
/* Shared markdown content styling for consistent rendering across components */
|
||||
.comfy-markdown-content {
|
||||
/* Typography */
|
||||
font-size: 0.875rem; /* text-sm */
|
||||
font-size: var(--comfy-textarea-font-size);
|
||||
line-height: 1.6;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
/* Headings */
|
||||
.comfy-markdown-content h1 {
|
||||
font-size: 22px; /* text-[22px] */
|
||||
font-size: calc(22 / 14 * var(--comfy-textarea-font-size));
|
||||
font-weight: 700; /* font-bold */
|
||||
margin-top: 2rem; /* mt-8 */
|
||||
margin-bottom: 1rem; /* mb-4 */
|
||||
@@ -745,7 +745,7 @@ body {
|
||||
}
|
||||
|
||||
.comfy-markdown-content h2 {
|
||||
font-size: 18px; /* text-[18px] */
|
||||
font-size: calc(18 / 14 * var(--comfy-textarea-font-size));
|
||||
font-weight: 700; /* font-bold */
|
||||
margin-top: 2rem; /* mt-8 */
|
||||
margin-bottom: 1rem; /* mb-4 */
|
||||
@@ -756,7 +756,7 @@ body {
|
||||
}
|
||||
|
||||
.comfy-markdown-content h3 {
|
||||
font-size: 16px; /* text-[16px] */
|
||||
font-size: calc(16 / 14 * var(--comfy-textarea-font-size));
|
||||
font-weight: 700; /* font-bold */
|
||||
margin-top: 2rem; /* mt-8 */
|
||||
margin-bottom: 1rem; /* mb-4 */
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
describe('runWhenGlobalIdle', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('falls back to a timeout when idle callbacks are unavailable', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.stubGlobal('requestIdleCallback', undefined)
|
||||
vi.stubGlobal('cancelIdleCallback', undefined)
|
||||
const { runWhenGlobalIdle } = await import('./async')
|
||||
const runner = vi.fn()
|
||||
|
||||
const disposable = runWhenGlobalIdle(runner)
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(runner).toHaveBeenCalledOnce()
|
||||
const deadline = runner.mock.calls[0][0]
|
||||
expect(deadline.didTimeout).toBe(true)
|
||||
expect(deadline.timeRemaining()).toBeGreaterThanOrEqual(0)
|
||||
|
||||
disposable.dispose()
|
||||
disposable.dispose()
|
||||
})
|
||||
|
||||
it('cancels fallback idle work before it runs', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.stubGlobal('requestIdleCallback', undefined)
|
||||
vi.stubGlobal('cancelIdleCallback', undefined)
|
||||
const { runWhenGlobalIdle } = await import('./async')
|
||||
const runner = vi.fn()
|
||||
|
||||
runWhenGlobalIdle(runner).dispose()
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(runner).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses native idle callbacks when available', async () => {
|
||||
const requestIdleCallback = vi.fn(() => 42)
|
||||
const cancelIdleCallback = vi.fn()
|
||||
vi.stubGlobal('requestIdleCallback', requestIdleCallback)
|
||||
vi.stubGlobal('cancelIdleCallback', cancelIdleCallback)
|
||||
const { runWhenGlobalIdle } = await import('./async')
|
||||
const runner = vi.fn()
|
||||
|
||||
const disposable = runWhenGlobalIdle(runner, 250)
|
||||
|
||||
expect(requestIdleCallback).toHaveBeenCalledWith(runner, { timeout: 250 })
|
||||
|
||||
disposable.dispose()
|
||||
disposable.dispose()
|
||||
|
||||
expect(cancelIdleCallback).toHaveBeenCalledOnce()
|
||||
expect(cancelIdleCallback).toHaveBeenCalledWith(42)
|
||||
})
|
||||
|
||||
it('omits native idle timeout options when no timeout is supplied', async () => {
|
||||
const requestIdleCallback = vi.fn(() => 7)
|
||||
vi.stubGlobal('requestIdleCallback', requestIdleCallback)
|
||||
vi.stubGlobal('cancelIdleCallback', vi.fn())
|
||||
const { runWhenGlobalIdle } = await import('./async')
|
||||
const runner = vi.fn()
|
||||
|
||||
runWhenGlobalIdle(runner)
|
||||
|
||||
expect(requestIdleCallback).toHaveBeenCalledWith(runner, undefined)
|
||||
})
|
||||
})
|
||||
@@ -122,22 +122,6 @@ describe('downloadUtil', () => {
|
||||
expect(createObjectURLSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('throws for an empty URL', () => {
|
||||
expect(() => downloadFile('')).toThrow(
|
||||
'Invalid URL provided for download'
|
||||
)
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
expect(createObjectURLSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('throws for a whitespace URL', () => {
|
||||
expect(() => downloadFile(' ')).toThrow(
|
||||
'Invalid URL provided for download'
|
||||
)
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
expect(createObjectURLSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should prefer custom filename over extracted filename', () => {
|
||||
const testUrl =
|
||||
'https://example.com/api/file?filename=extracted-image.jpg'
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
CREDITS_PER_USD,
|
||||
COMFY_CREDIT_RATE_CENTS,
|
||||
centsToCredits,
|
||||
clampUsd,
|
||||
creditsToCents,
|
||||
creditsToUsd,
|
||||
formatCredits,
|
||||
@@ -44,21 +43,4 @@ describe('comfyCredits helpers', () => {
|
||||
expect(formatCreditsFromUsd({ usd: 1, locale })).toBe('211.00')
|
||||
expect(formatUsd({ value: 4.2, locale })).toBe('4.20')
|
||||
})
|
||||
|
||||
test('formats with compatible fraction digit bounds', () => {
|
||||
expect(
|
||||
formatCredits({
|
||||
value: 12.345,
|
||||
locale: 'en-US',
|
||||
numberOptions: { minimumFractionDigits: 4, maximumFractionDigits: 2 }
|
||||
})
|
||||
).toBe('12.35')
|
||||
})
|
||||
|
||||
test('clamps USD purchase values into the supported range', () => {
|
||||
expect(clampUsd(Number.NaN)).toBe(0)
|
||||
expect(clampUsd(-5)).toBe(1)
|
||||
expect(clampUsd(42)).toBe(42)
|
||||
expect(clampUsd(5000)).toBe(1000)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -22,7 +22,7 @@ import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { parseImageWidgetValue } from '@/utils/imageUtil'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { HideLayoutFieldKey } from '@/types/widgetTypes'
|
||||
import { HideLayoutFieldKey, WidgetHeightKey } from '@/types/widgetTypes'
|
||||
import { UNASSIGNED_NODE_ID } from '@/types/nodeId'
|
||||
import { promptRenameWidget } from '@/utils/widgetUtil'
|
||||
|
||||
@@ -50,6 +50,7 @@ const { onPointerDown } = useAppModeWidgetResizing((widget, config) =>
|
||||
)
|
||||
|
||||
provide(HideLayoutFieldKey, true)
|
||||
provide(WidgetHeightKey, mobile ? 'h-10' : 'h-7')
|
||||
|
||||
const resolvedInputs = useResolvedSelectedInputs()
|
||||
|
||||
@@ -236,7 +237,7 @@ defineExpose({ handleDragDrop })
|
||||
:node-data
|
||||
:class="
|
||||
cn(
|
||||
'gap-y-3 rounded-lg py-1 [&_textarea]:resize-y **:[.col-span-2]:grid-cols-1 not-md:**:[.h-7]:h-10',
|
||||
'gap-y-3 rounded-lg py-1 [&_textarea]:resize-y **:[.col-span-2]:grid-cols-1',
|
||||
nodeData.hasErrors && 'ring-2 ring-node-stroke-error ring-inset'
|
||||
)
|
||||
"
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
<template>
|
||||
<div
|
||||
ref="container"
|
||||
class="flex h-7 rounded-lg bg-component-node-widget-background text-xs text-component-node-foreground"
|
||||
:class="
|
||||
cn(
|
||||
'flex overflow-hidden rounded-md bg-component-node-widget-background text-xs text-component-node-foreground',
|
||||
useWidgetHeight()
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot name="background" />
|
||||
<Button
|
||||
v-if="!hideButtons"
|
||||
:aria-label="t('g.decrement')"
|
||||
data-testid="decrement"
|
||||
class="aspect-8/7 h-full rounded-r-none hover:bg-base-foreground/20 disabled:opacity-30"
|
||||
class="aspect-square h-full rounded-none p-0 hover:bg-component-node-widget-background-hovered disabled:opacity-30"
|
||||
variant="muted-textonly"
|
||||
size="unset"
|
||||
:disabled="!canDecrement"
|
||||
tabindex="-1"
|
||||
@click="modelValue = clamp(modelValue - step)"
|
||||
>
|
||||
<i class="pi pi-minus" />
|
||||
<i class="icon-[lucide--minus]" />
|
||||
</Button>
|
||||
<div class="relative my-0.25 min-w-[4ch] flex-1 py-1.5">
|
||||
<input
|
||||
@@ -24,7 +30,7 @@
|
||||
:disabled
|
||||
:class="
|
||||
cn(
|
||||
'absolute inset-0 truncate border-0 bg-transparent p-1 text-sm focus:outline-0'
|
||||
'absolute inset-0 truncate border-0 bg-transparent p-1 text-xs focus:outline-0'
|
||||
)
|
||||
"
|
||||
inputmode="decimal"
|
||||
@@ -54,13 +60,14 @@
|
||||
v-if="!hideButtons"
|
||||
:aria-label="t('g.increment')"
|
||||
data-testid="increment"
|
||||
class="aspect-8/7 h-full rounded-l-none hover:bg-base-foreground/20 disabled:opacity-30"
|
||||
class="aspect-square h-full rounded-none p-0 hover:bg-component-node-widget-background-hovered disabled:opacity-30"
|
||||
variant="muted-textonly"
|
||||
size="unset"
|
||||
:disabled="!canIncrement"
|
||||
tabindex="-1"
|
||||
@click="modelValue = clamp(modelValue + step)"
|
||||
>
|
||||
<i class="pi pi-plus" />
|
||||
<i class="icon-[lucide--plus]" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -71,6 +78,7 @@ import { computed, ref, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useWidgetHeight } from '@/types/widgetTypes'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const {
|
||||
|
||||
@@ -42,22 +42,34 @@ function withStrictMillisecondParser<T>(run: () => T): T {
|
||||
}
|
||||
|
||||
const mockSubscription = vi.hoisted(() => ({
|
||||
value: null as { endDate: string | null } | null
|
||||
value: null as {
|
||||
endDate: string | null
|
||||
duration?: 'ANNUAL' | 'MONTHLY' | null
|
||||
} | null
|
||||
}))
|
||||
|
||||
const mockCancelSubscription = vi.hoisted(() => vi.fn())
|
||||
const mockFetchStatus = vi.hoisted(() => vi.fn())
|
||||
const mockCloseDialog = vi.hoisted(() => vi.fn())
|
||||
const mockToastAdd = vi.hoisted(() => vi.fn())
|
||||
const mockTier = vi.hoisted(() => ({ value: 'STANDARD' as string | null }))
|
||||
const mockTrackCancellation = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: vi.fn(() => ({
|
||||
cancelSubscription: mockCancelSubscription,
|
||||
fetchStatus: mockFetchStatus,
|
||||
subscription: mockSubscription
|
||||
subscription: mockSubscription,
|
||||
tier: mockTier
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({
|
||||
trackSubscriptionCancellation: mockTrackCancellation
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: vi.fn(() => ({
|
||||
closeDialog: mockCloseDialog
|
||||
@@ -94,6 +106,95 @@ function renderComponent(props: { cancelAt?: string } = {}) {
|
||||
describe('CancelSubscriptionDialogContent', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockTier.value = 'STANDARD'
|
||||
})
|
||||
|
||||
describe('cancellation telemetry', () => {
|
||||
it('tracks flow_opened with tier and end date when the dialog mounts', () => {
|
||||
mockSubscription.value = { endDate: '2026-08-01T00:00:00.000Z' }
|
||||
|
||||
renderComponent()
|
||||
|
||||
expect(mockTrackCancellation).toHaveBeenCalledWith('flow_opened', {
|
||||
source: 'cancel_plan_menu',
|
||||
current_tier: 'standard',
|
||||
end_date: '2026-08-01T00:00:00.000Z'
|
||||
})
|
||||
})
|
||||
|
||||
it('tracks confirmed before the cancel request and no abandoned on success', async () => {
|
||||
mockSubscription.value = null
|
||||
mockCancelSubscription.mockResolvedValueOnce(undefined)
|
||||
|
||||
const { unmount } = renderComponent()
|
||||
await userEvent.click(
|
||||
screen.getByRole('button', { name: /^cancel subscription$/i })
|
||||
)
|
||||
|
||||
await waitFor(() => expect(mockCloseDialog).toHaveBeenCalled())
|
||||
unmount()
|
||||
expect(mockTrackCancellation).toHaveBeenCalledWith(
|
||||
'confirmed',
|
||||
expect.objectContaining({ current_tier: 'standard' })
|
||||
)
|
||||
expect(mockTrackCancellation).not.toHaveBeenCalledWith(
|
||||
'abandoned',
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('tracks confirmed and failed with message-carrying rejection values', async () => {
|
||||
mockSubscription.value = null
|
||||
mockCancelSubscription.mockRejectedValueOnce({ message: 'timed out' })
|
||||
|
||||
renderComponent()
|
||||
await userEvent.click(
|
||||
screen.getByRole('button', { name: /^cancel subscription$/i })
|
||||
)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockTrackCancellation).toHaveBeenCalledWith(
|
||||
'failed',
|
||||
expect.objectContaining({ error_message: 'timed out' })
|
||||
)
|
||||
)
|
||||
expect(mockTrackCancellation).toHaveBeenCalledWith(
|
||||
'confirmed',
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('tracks abandoned when the user keeps the subscription', async () => {
|
||||
mockSubscription.value = null
|
||||
|
||||
const { unmount } = renderComponent()
|
||||
await userEvent.click(
|
||||
screen.getByRole('button', { name: /keep subscription/i })
|
||||
)
|
||||
|
||||
expect(mockCloseDialog).toHaveBeenCalledWith({
|
||||
key: 'cancel-subscription'
|
||||
})
|
||||
unmount()
|
||||
expect(mockTrackCancellation).toHaveBeenCalledWith(
|
||||
'abandoned',
|
||||
expect.objectContaining({ current_tier: 'standard' })
|
||||
)
|
||||
expect(mockCancelSubscription).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('tracks abandoned when the dialog is dismissed by the shell', () => {
|
||||
mockSubscription.value = null
|
||||
|
||||
const { unmount } = renderComponent()
|
||||
mockTrackCancellation.mockClear()
|
||||
unmount()
|
||||
|
||||
expect(mockTrackCancellation).toHaveBeenCalledWith(
|
||||
'abandoned',
|
||||
expect.objectContaining({ current_tier: 'standard' })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cancel flow', () => {
|
||||
@@ -138,6 +239,35 @@ describe('CancelSubscriptionDialogContent', () => {
|
||||
expect.objectContaining({ severity: 'success' })
|
||||
)
|
||||
})
|
||||
|
||||
it('does not track cancellation failure when status refresh fails after cancellation succeeds', async () => {
|
||||
mockSubscription.value = null
|
||||
mockCancelSubscription.mockResolvedValueOnce(undefined)
|
||||
mockFetchStatus.mockRejectedValueOnce(new Error('Refresh failed'))
|
||||
|
||||
const { unmount } = renderComponent()
|
||||
await userEvent.click(
|
||||
screen.getByRole('button', { name: /^cancel subscription$/i })
|
||||
)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'success' })
|
||||
)
|
||||
)
|
||||
expect(mockCloseDialog).toHaveBeenCalledWith({
|
||||
key: 'cancel-subscription'
|
||||
})
|
||||
expect(
|
||||
mockTrackCancellation.mock.calls.some(([stage]) => stage === 'failed')
|
||||
).toBe(false)
|
||||
|
||||
unmount()
|
||||
expect(mockTrackCancellation).not.toHaveBeenCalledWith(
|
||||
'abandoned',
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formattedEndDate fallbacks', () => {
|
||||
|
||||
@@ -45,13 +45,16 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import type { SubscriptionCancellationMetadata } from '@/platform/telemetry/types'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { parseIsoDateSafe } from '@/utils/dateTimeUtil'
|
||||
import { getErrorMessage } from '@/utils/errorUtil'
|
||||
|
||||
const props = defineProps<{
|
||||
cancelAt?: string
|
||||
@@ -60,9 +63,41 @@ const props = defineProps<{
|
||||
const { t } = useI18n()
|
||||
const dialogStore = useDialogStore()
|
||||
const toast = useToast()
|
||||
const { cancelSubscription, fetchStatus, subscription } = useBillingContext()
|
||||
const { cancelSubscription, fetchStatus, subscription, tier } =
|
||||
useBillingContext()
|
||||
const telemetry = useTelemetry()
|
||||
|
||||
const isLoading = ref(false)
|
||||
const didCancelSucceed = ref(false)
|
||||
|
||||
function cancellationMetadata(): SubscriptionCancellationMetadata {
|
||||
const endDate = props.cancelAt ?? subscription.value?.endDate
|
||||
return {
|
||||
source: 'cancel_plan_menu' as const,
|
||||
current_tier: tier.value?.toLowerCase(),
|
||||
...(subscription.value?.duration
|
||||
? {
|
||||
cycle:
|
||||
subscription.value.duration === 'ANNUAL'
|
||||
? ('yearly' as const)
|
||||
: ('monthly' as const)
|
||||
}
|
||||
: {}),
|
||||
...(endDate ? { end_date: endDate } : {})
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
telemetry?.trackSubscriptionCancellation(
|
||||
'flow_opened',
|
||||
cancellationMetadata()
|
||||
)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (didCancelSucceed.value || isLoading.value) return
|
||||
telemetry?.trackSubscriptionCancellation('abandoned', cancellationMetadata())
|
||||
})
|
||||
|
||||
const formattedEndDate = computed(() => {
|
||||
const date = parseIsoDateSafe(props.cancelAt ?? subscription.value?.endDate)
|
||||
@@ -84,24 +119,37 @@ function onClose() {
|
||||
}
|
||||
|
||||
async function onConfirmCancel() {
|
||||
telemetry?.trackSubscriptionCancellation('confirmed', cancellationMetadata())
|
||||
isLoading.value = true
|
||||
try {
|
||||
await cancelSubscription()
|
||||
await fetchStatus()
|
||||
dialogStore.closeDialog({ key: 'cancel-subscription' })
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('subscription.cancelSuccess'),
|
||||
life: 5000
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error)
|
||||
telemetry?.trackSubscriptionCancellation('failed', {
|
||||
...cancellationMetadata(),
|
||||
error_message: errorMessage ?? String(error)
|
||||
})
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('subscription.cancelDialog.failed'),
|
||||
detail: error instanceof Error ? error.message : t('g.unknownError')
|
||||
detail: errorMessage ?? t('g.unknownError')
|
||||
})
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
didCancelSucceed.value = true
|
||||
try {
|
||||
await fetchStatus()
|
||||
} catch {
|
||||
// Cancellation already succeeded; stale local subscription status should not report failure.
|
||||
}
|
||||
dialogStore.closeDialog({ key: 'cancel-subscription' })
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('subscription.cancelSuccess'),
|
||||
life: 5000
|
||||
})
|
||||
isLoading.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -31,7 +31,7 @@ import { getWidgetDefaultValue } from '@/utils/widgetUtil'
|
||||
import type { WidgetValue } from '@/utils/widgetUtil'
|
||||
|
||||
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
|
||||
import { HideLayoutFieldKey } from '@/types/widgetTypes'
|
||||
import { HideLayoutFieldKey, WidgetHeightKey } from '@/types/widgetTypes'
|
||||
|
||||
import { GetNodeParentGroupKey } from '../shared'
|
||||
import WidgetItem from './WidgetItem.vue'
|
||||
@@ -135,6 +135,7 @@ watchDebounced(
|
||||
onBeforeUnmount(() => draggableList.value?.dispose())
|
||||
|
||||
provide(HideLayoutFieldKey, true)
|
||||
provide(WidgetHeightKey, 'h-7')
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
|
||||
@@ -8,16 +8,7 @@ import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workfl
|
||||
type ModifiedWorkflow = Pick<ComfyWorkflow, 'path' | 'isModified'>
|
||||
|
||||
const mockAuthStore = vi.hoisted(() => ({
|
||||
logout: vi.fn().mockResolvedValue(undefined),
|
||||
sendPasswordReset: vi.fn().mockResolvedValue(undefined),
|
||||
initiateCreditPurchase: vi.fn(),
|
||||
accessBillingPortal: vi.fn(),
|
||||
fetchBalance: vi.fn(),
|
||||
loginWithGoogle: vi.fn(),
|
||||
loginWithGithub: vi.fn(),
|
||||
login: vi.fn(),
|
||||
register: vi.fn(),
|
||||
updatePassword: vi.fn().mockResolvedValue(undefined)
|
||||
logout: vi.fn().mockResolvedValue(undefined)
|
||||
}))
|
||||
|
||||
const mockToastStore = vi.hoisted(() => ({
|
||||
@@ -38,16 +29,6 @@ const mockDialogService = vi.hoisted(() => ({
|
||||
|
||||
const mockToastErrorHandler = vi.hoisted(() => vi.fn())
|
||||
|
||||
const mockBillingContext = vi.hoisted(() => ({
|
||||
isActiveSubscription: { value: false },
|
||||
isFreeTier: { value: true },
|
||||
type: { value: 'free' }
|
||||
}))
|
||||
|
||||
const mockTelemetry = vi.hoisted(() => ({
|
||||
startTopupTracking: vi.fn()
|
||||
}))
|
||||
|
||||
const knownAuthErrorCodes = new Set([
|
||||
'auth/invalid-credential',
|
||||
'auth/email-already-in-use'
|
||||
@@ -67,7 +48,7 @@ vi.mock('@/platform/distribution/types', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: vi.fn(() => mockTelemetry)
|
||||
useTelemetry: vi.fn(() => undefined)
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
@@ -91,7 +72,11 @@ vi.mock('@/stores/authStore', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: vi.fn(() => mockBillingContext)
|
||||
useBillingContext: vi.fn(() => ({
|
||||
isActiveSubscription: { value: false },
|
||||
isFreeTier: { value: true },
|
||||
type: { value: 'free' }
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useErrorHandling', () => ({
|
||||
@@ -112,7 +97,6 @@ describe('useAuthActions.logout', () => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
mockWorkflowStore.modifiedWorkflows = []
|
||||
mockBillingContext.isActiveSubscription.value = false
|
||||
})
|
||||
|
||||
it('logs out without prompting when no workflows are modified', async () => {
|
||||
@@ -297,158 +281,4 @@ describe('useAuthActions.reportError', () => {
|
||||
expect(mockToastErrorHandler).toHaveBeenCalledWith(networkError)
|
||||
expect(mockToastStore.add).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows the unauthorized-domain access error message', () => {
|
||||
const { reportError, accessError } = useAuthActions()
|
||||
|
||||
reportError(new FirebaseError('auth/unauthorized-domain', 'blocked'))
|
||||
|
||||
expect(accessError.value).toBe(true)
|
||||
expect(mockToastStore.add).toHaveBeenCalledWith({
|
||||
severity: 'error',
|
||||
summary: 'g.error',
|
||||
detail: 'toastMessages.unauthorizedDomain'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('useAuthActions account actions', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
mockBillingContext.isActiveSubscription.value = false
|
||||
vi.stubGlobal(
|
||||
'open',
|
||||
vi.fn(() => ({}))
|
||||
)
|
||||
})
|
||||
|
||||
it('sends password reset emails and shows success toast', async () => {
|
||||
const { sendPasswordReset } = useAuthActions()
|
||||
|
||||
await sendPasswordReset('user@example.com')
|
||||
|
||||
expect(mockAuthStore.sendPasswordReset).toHaveBeenCalledWith(
|
||||
'user@example.com'
|
||||
)
|
||||
expect(mockToastStore.add).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
severity: 'success',
|
||||
summary: 'auth.login.passwordResetSent'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('does not purchase credits without an active subscription', async () => {
|
||||
const { purchaseCredits } = useAuthActions()
|
||||
|
||||
await purchaseCredits(25)
|
||||
|
||||
expect(mockAuthStore.initiateCreditPurchase).not.toHaveBeenCalled()
|
||||
expect(window.open).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens checkout and tracks top-up starts for credit purchases', async () => {
|
||||
mockBillingContext.isActiveSubscription.value = true
|
||||
mockAuthStore.initiateCreditPurchase.mockResolvedValueOnce({
|
||||
checkout_url: 'https://checkout.example.test'
|
||||
})
|
||||
const { purchaseCredits } = useAuthActions()
|
||||
|
||||
await purchaseCredits(25)
|
||||
|
||||
expect(mockAuthStore.initiateCreditPurchase).toHaveBeenCalledWith({
|
||||
amount_micros: 25000000,
|
||||
currency: 'usd'
|
||||
})
|
||||
expect(mockTelemetry.startTopupTracking).toHaveBeenCalledOnce()
|
||||
expect(window.open).toHaveBeenCalledWith(
|
||||
'https://checkout.example.test',
|
||||
'_blank'
|
||||
)
|
||||
})
|
||||
|
||||
it('throws when credit checkout URL is missing', async () => {
|
||||
mockBillingContext.isActiveSubscription.value = true
|
||||
mockAuthStore.initiateCreditPurchase.mockResolvedValueOnce({})
|
||||
const { purchaseCredits } = useAuthActions()
|
||||
|
||||
await expect(purchaseCredits(10)).rejects.toThrow(
|
||||
'toastMessages.failedToPurchaseCredits'
|
||||
)
|
||||
})
|
||||
|
||||
it('opens the billing portal in a new tab by default', async () => {
|
||||
mockAuthStore.accessBillingPortal.mockResolvedValueOnce({
|
||||
billing_portal_url: 'https://billing.example.test'
|
||||
})
|
||||
const { accessBillingPortal } = useAuthActions()
|
||||
|
||||
await expect(accessBillingPortal('pro')).resolves.toBe(true)
|
||||
|
||||
expect(mockAuthStore.accessBillingPortal).toHaveBeenCalledWith('pro')
|
||||
expect(window.open).toHaveBeenCalledWith(
|
||||
'https://billing.example.test',
|
||||
'_blank'
|
||||
)
|
||||
})
|
||||
|
||||
it('throws when billing portal URL is missing', async () => {
|
||||
mockAuthStore.accessBillingPortal.mockResolvedValueOnce({})
|
||||
const { accessBillingPortal } = useAuthActions()
|
||||
|
||||
await expect(accessBillingPortal()).rejects.toThrow(
|
||||
'toastMessages.failedToAccessBillingPortal'
|
||||
)
|
||||
})
|
||||
|
||||
it('delegates balance and sign-in methods to the auth store', async () => {
|
||||
mockAuthStore.fetchBalance.mockResolvedValueOnce({ balance: 12 })
|
||||
mockAuthStore.loginWithGoogle.mockResolvedValueOnce('google')
|
||||
mockAuthStore.loginWithGithub.mockResolvedValueOnce('github')
|
||||
mockAuthStore.login.mockResolvedValueOnce('email')
|
||||
mockAuthStore.register.mockResolvedValueOnce('registered')
|
||||
const actions = useAuthActions()
|
||||
|
||||
await expect(actions.fetchBalance()).resolves.toEqual({ balance: 12 })
|
||||
await expect(actions.signInWithGoogle({ isNewUser: true })).resolves.toBe(
|
||||
'google'
|
||||
)
|
||||
await expect(actions.signInWithGithub({ isNewUser: false })).resolves.toBe(
|
||||
'github'
|
||||
)
|
||||
await expect(actions.signInWithEmail('u@example.com', 'pw')).resolves.toBe(
|
||||
'email'
|
||||
)
|
||||
await expect(
|
||||
actions.signUpWithEmail('u@example.com', 'pw', 'turnstile')
|
||||
).resolves.toBe('registered')
|
||||
|
||||
expect(mockAuthStore.loginWithGoogle).toHaveBeenCalledWith({
|
||||
isNewUser: true
|
||||
})
|
||||
expect(mockAuthStore.loginWithGithub).toHaveBeenCalledWith({
|
||||
isNewUser: false
|
||||
})
|
||||
expect(mockAuthStore.login).toHaveBeenCalledWith('u@example.com', 'pw')
|
||||
expect(mockAuthStore.register).toHaveBeenCalledWith(
|
||||
'u@example.com',
|
||||
'pw',
|
||||
'turnstile'
|
||||
)
|
||||
})
|
||||
|
||||
it('updates passwords and shows success toast', async () => {
|
||||
const { updatePassword } = useAuthActions()
|
||||
|
||||
await updatePassword('new-password')
|
||||
|
||||
expect(mockAuthStore.updatePassword).toHaveBeenCalledWith('new-password')
|
||||
expect(mockToastStore.add).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
severity: 'success',
|
||||
summary: 'auth.passwordUpdate.success'
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, reactive } from 'vue'
|
||||
|
||||
import type { User as FirebaseUser } from 'firebase/auth'
|
||||
|
||||
import type { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
|
||||
|
||||
type FirebaseUserMock = Pick<
|
||||
FirebaseUser,
|
||||
'uid' | 'displayName' | 'email' | 'photoURL'
|
||||
> & {
|
||||
providerData: Array<Pick<FirebaseUser['providerData'][number], 'providerId'>>
|
||||
}
|
||||
|
||||
type ApiKeyUser = NonNullable<
|
||||
ReturnType<typeof useApiKeyAuthStore>['currentUser']
|
||||
>
|
||||
|
||||
const mockStores = vi.hoisted(() => ({
|
||||
authStore: undefined as
|
||||
| undefined
|
||||
| {
|
||||
currentUser: FirebaseUserMock | null
|
||||
loading: boolean
|
||||
tokenRefreshTrigger: number
|
||||
},
|
||||
apiKeyStore: undefined as
|
||||
| undefined
|
||||
| {
|
||||
isAuthenticated: boolean
|
||||
currentUser: ApiKeyUser | null
|
||||
clearStoredApiKey: ReturnType<typeof vi.fn>
|
||||
},
|
||||
commandStore: undefined as
|
||||
| undefined
|
||||
| {
|
||||
execute: ReturnType<typeof vi.fn>
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/authStore', () => ({
|
||||
useAuthStore: () => mockStores.authStore
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/apiKeyAuthStore', () => ({
|
||||
useApiKeyAuthStore: () => mockStores.apiKeyStore
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/commandStore', () => ({
|
||||
useCommandStore: () => mockStores.commandStore
|
||||
}))
|
||||
|
||||
async function setup() {
|
||||
vi.resetModules()
|
||||
const authStore = reactive({
|
||||
currentUser: null as FirebaseUserMock | null,
|
||||
loading: false,
|
||||
tokenRefreshTrigger: 0
|
||||
})
|
||||
const apiKeyStore = reactive({
|
||||
isAuthenticated: false,
|
||||
currentUser: null as ApiKeyUser | null,
|
||||
clearStoredApiKey: vi.fn()
|
||||
})
|
||||
const commandStore = {
|
||||
execute: vi.fn()
|
||||
}
|
||||
|
||||
mockStores.authStore = authStore
|
||||
mockStores.apiKeyStore = apiKeyStore
|
||||
mockStores.commandStore = commandStore
|
||||
|
||||
const { useCurrentUser } = await import('./useCurrentUser')
|
||||
return {
|
||||
currentUser: useCurrentUser(),
|
||||
authStore,
|
||||
apiKeyStore,
|
||||
commandStore
|
||||
}
|
||||
}
|
||||
|
||||
function firebaseUser(
|
||||
providerId: string,
|
||||
overrides: Partial<FirebaseUserMock> = {}
|
||||
): FirebaseUserMock {
|
||||
return {
|
||||
uid: 'firebase-user',
|
||||
displayName: 'Firebase User',
|
||||
email: 'firebase@example.com',
|
||||
photoURL: 'https://example.com/photo.png',
|
||||
providerData: [{ providerId }],
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('useCurrentUser', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('reports logged-out state when no auth source is active', async () => {
|
||||
const { currentUser } = await setup()
|
||||
|
||||
expect(currentUser.loading).toBe(false)
|
||||
expect(currentUser.isLoggedIn.value).toBe(false)
|
||||
expect(currentUser.resolvedUserInfo.value).toBeNull()
|
||||
expect(currentUser.userDisplayName.value).toBeUndefined()
|
||||
expect(currentUser.userEmail.value).toBeUndefined()
|
||||
expect(currentUser.userPhotoUrl.value).toBeUndefined()
|
||||
expect(currentUser.providerName.value).toBeUndefined()
|
||||
expect(currentUser.providerIcon.value).toBe('pi pi-user')
|
||||
expect(currentUser.isEmailProvider.value).toBe(false)
|
||||
})
|
||||
|
||||
it('uses API key user identity before firebase identity', async () => {
|
||||
const { currentUser, authStore, apiKeyStore } = await setup()
|
||||
authStore.currentUser = firebaseUser('google.com')
|
||||
apiKeyStore.isAuthenticated = true
|
||||
apiKeyStore.currentUser = {
|
||||
id: 'api-user',
|
||||
name: 'API User',
|
||||
email: 'api@example.com'
|
||||
}
|
||||
|
||||
expect(currentUser.isLoggedIn.value).toBe(true)
|
||||
expect(currentUser.isApiKeyLogin.value).toBe(true)
|
||||
expect(currentUser.resolvedUserInfo.value).toEqual({ id: 'api-user' })
|
||||
expect(currentUser.userDisplayName.value).toBe('API User')
|
||||
expect(currentUser.userEmail.value).toBe('api@example.com')
|
||||
expect(currentUser.userPhotoUrl.value).toBeNull()
|
||||
expect(currentUser.providerName.value).toBe('Comfy API Key')
|
||||
expect(currentUser.providerIcon.value).toBe('pi pi-key')
|
||||
expect(currentUser.isEmailProvider.value).toBe(false)
|
||||
})
|
||||
|
||||
it('maps firebase provider metadata to display fields', async () => {
|
||||
const { currentUser, authStore } = await setup()
|
||||
|
||||
authStore.currentUser = firebaseUser('google.com')
|
||||
expect(currentUser.providerName.value).toBe('Google')
|
||||
expect(currentUser.providerIcon.value).toBe('pi pi-google')
|
||||
expect(currentUser.userDisplayName.value).toBe('Firebase User')
|
||||
expect(currentUser.userEmail.value).toBe('firebase@example.com')
|
||||
expect(currentUser.userPhotoUrl.value).toBe('https://example.com/photo.png')
|
||||
expect(currentUser.resolvedUserInfo.value).toEqual({ id: 'firebase-user' })
|
||||
|
||||
authStore.currentUser = firebaseUser('github.com')
|
||||
expect(currentUser.providerName.value).toBe('GitHub')
|
||||
expect(currentUser.providerIcon.value).toBe('pi pi-github')
|
||||
|
||||
authStore.currentUser = firebaseUser('password')
|
||||
expect(currentUser.providerName.value).toBe('password')
|
||||
expect(currentUser.providerIcon.value).toBe('pi pi-user')
|
||||
expect(currentUser.isEmailProvider.value).toBe(true)
|
||||
})
|
||||
|
||||
it('routes sign out through the active auth source', async () => {
|
||||
const { currentUser, apiKeyStore, commandStore } = await setup()
|
||||
|
||||
apiKeyStore.isAuthenticated = true
|
||||
apiKeyStore.currentUser = { id: 'api-user' }
|
||||
await currentUser.handleSignOut()
|
||||
expect(apiKeyStore.clearStoredApiKey).toHaveBeenCalledOnce()
|
||||
|
||||
apiKeyStore.isAuthenticated = false
|
||||
await currentUser.handleSignOut()
|
||||
expect(commandStore.execute).toHaveBeenCalledWith('Comfy.User.SignOut')
|
||||
})
|
||||
|
||||
it('opens the sign-in dialog through the command store', async () => {
|
||||
const { currentUser, commandStore } = await setup()
|
||||
|
||||
await currentUser.handleSignIn()
|
||||
|
||||
expect(commandStore.execute).toHaveBeenCalledWith(
|
||||
'Comfy.User.OpenSignInDialog'
|
||||
)
|
||||
})
|
||||
|
||||
it('runs user lifecycle callbacks for resolve, token refresh, and logout', async () => {
|
||||
const { currentUser, authStore } = await setup()
|
||||
const resolved = vi.fn()
|
||||
const tokenRefreshed = vi.fn()
|
||||
const logout = vi.fn()
|
||||
|
||||
currentUser.onUserResolved(resolved)
|
||||
currentUser.onTokenRefreshed(tokenRefreshed)
|
||||
currentUser.onUserLogout(logout)
|
||||
|
||||
authStore.currentUser = firebaseUser('google.com')
|
||||
await nextTick()
|
||||
expect(resolved.mock.calls[0][0]).toEqual({ id: 'firebase-user' })
|
||||
|
||||
authStore.tokenRefreshTrigger += 1
|
||||
await nextTick()
|
||||
expect(tokenRefreshed).toHaveBeenCalledOnce()
|
||||
|
||||
authStore.currentUser = null
|
||||
await nextTick()
|
||||
expect(logout).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('runs onUserResolved immediately when a user already exists', async () => {
|
||||
const { currentUser, apiKeyStore } = await setup()
|
||||
apiKeyStore.isAuthenticated = true
|
||||
apiKeyStore.currentUser = { id: 'api-user' }
|
||||
const resolved = vi.fn()
|
||||
|
||||
currentUser.onUserResolved(resolved)
|
||||
|
||||
expect(resolved.mock.calls[0][0]).toEqual({ id: 'api-user' })
|
||||
})
|
||||
})
|
||||
@@ -1,256 +0,0 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useLegacyBilling } from './useLegacyBilling'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
isActiveSubscription: { value: false },
|
||||
subscriptionTier: { value: null as string | null },
|
||||
subscriptionDuration: { value: null as string | null },
|
||||
subscriptionStatus: {
|
||||
value: null as null | {
|
||||
renewal_date?: string | null
|
||||
end_date?: string | null
|
||||
}
|
||||
},
|
||||
isCancelled: { value: false },
|
||||
fetchStatus: vi.fn(),
|
||||
manageSubscription: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
showSubscriptionDialog: vi.fn(),
|
||||
balance: {
|
||||
value: null as null | {
|
||||
amount_micros?: number
|
||||
currency?: string
|
||||
effective_balance_micros?: number
|
||||
prepaid_balance_micros?: number
|
||||
cloud_credit_balance_micros?: number
|
||||
}
|
||||
},
|
||||
fetchBalance: vi.fn(),
|
||||
purchaseCredits: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
|
||||
useSubscription: () => ({
|
||||
isActiveSubscription: mocks.isActiveSubscription,
|
||||
subscriptionTier: mocks.subscriptionTier,
|
||||
subscriptionDuration: mocks.subscriptionDuration,
|
||||
subscriptionStatus: mocks.subscriptionStatus,
|
||||
isCancelled: mocks.isCancelled,
|
||||
fetchStatus: mocks.fetchStatus,
|
||||
manageSubscription: mocks.manageSubscription,
|
||||
subscribe: mocks.subscribe,
|
||||
showSubscriptionDialog: mocks.showSubscriptionDialog
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/authStore', () => ({
|
||||
useAuthStore: () => ({
|
||||
get balance() {
|
||||
return mocks.balance.value
|
||||
},
|
||||
fetchBalance: mocks.fetchBalance
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/auth/useAuthActions', () => ({
|
||||
useAuthActions: () => ({
|
||||
purchaseCredits: mocks.purchaseCredits
|
||||
})
|
||||
}))
|
||||
|
||||
describe('useLegacyBilling', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
mocks.isActiveSubscription.value = false
|
||||
mocks.subscriptionTier.value = null
|
||||
mocks.subscriptionDuration.value = null
|
||||
mocks.subscriptionStatus.value = null
|
||||
mocks.isCancelled.value = false
|
||||
mocks.balance.value = null
|
||||
mocks.fetchStatus.mockResolvedValue(undefined)
|
||||
mocks.manageSubscription.mockResolvedValue(undefined)
|
||||
mocks.subscribe.mockResolvedValue(undefined)
|
||||
mocks.fetchBalance.mockResolvedValue(undefined)
|
||||
mocks.purchaseCredits.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('returns empty subscription and balance state without legacy data', () => {
|
||||
const billing = useLegacyBilling()
|
||||
|
||||
expect(billing.subscription.value).toBeNull()
|
||||
expect(billing.balance.value).toBeNull()
|
||||
expect(billing.subscriptionStatus.value).toBeNull()
|
||||
expect(billing.renewalDate.value).toBeNull()
|
||||
expect(billing.isFreeTier.value).toBe(false)
|
||||
})
|
||||
|
||||
it('maps active subscription and explicit balance fields', () => {
|
||||
mocks.isActiveSubscription.value = true
|
||||
mocks.subscriptionTier.value = 'PRO'
|
||||
mocks.subscriptionDuration.value = 'MONTHLY'
|
||||
mocks.subscriptionStatus.value = {
|
||||
renewal_date: '2026-01-01T00:00:00Z',
|
||||
end_date: '2026-02-01T00:00:00Z'
|
||||
}
|
||||
mocks.balance.value = {
|
||||
amount_micros: 500,
|
||||
currency: 'eur',
|
||||
effective_balance_micros: 400,
|
||||
prepaid_balance_micros: 300,
|
||||
cloud_credit_balance_micros: 200
|
||||
}
|
||||
|
||||
const billing = useLegacyBilling()
|
||||
|
||||
expect(billing.subscription.value).toEqual({
|
||||
isActive: true,
|
||||
tier: 'PRO',
|
||||
duration: 'MONTHLY',
|
||||
planSlug: null,
|
||||
renewalDate: '2026-01-01T00:00:00Z',
|
||||
endDate: '2026-02-01T00:00:00Z',
|
||||
isCancelled: false,
|
||||
hasFunds: true
|
||||
})
|
||||
expect(billing.balance.value).toEqual({
|
||||
amountMicros: 500,
|
||||
currency: 'eur',
|
||||
effectiveBalanceMicros: 400,
|
||||
prepaidBalanceMicros: 300,
|
||||
cloudCreditBalanceMicros: 200
|
||||
})
|
||||
expect(billing.subscriptionStatus.value).toBe('active')
|
||||
})
|
||||
|
||||
it('uses legacy balance defaults when optional fields are absent', () => {
|
||||
mocks.subscriptionTier.value = 'FREE'
|
||||
mocks.balance.value = {}
|
||||
|
||||
const billing = useLegacyBilling()
|
||||
|
||||
expect(billing.balance.value).toEqual({
|
||||
amountMicros: 0,
|
||||
currency: 'usd',
|
||||
effectiveBalanceMicros: 0,
|
||||
prepaidBalanceMicros: 0,
|
||||
cloudCreditBalanceMicros: 0
|
||||
})
|
||||
expect(billing.subscription.value?.hasFunds).toBe(false)
|
||||
})
|
||||
|
||||
it('uses amount as effective balance when only amount is present', () => {
|
||||
mocks.balance.value = { amount_micros: 250 }
|
||||
|
||||
const billing = useLegacyBilling()
|
||||
|
||||
expect(billing.balance.value?.effectiveBalanceMicros).toBe(250)
|
||||
})
|
||||
|
||||
it('reports canceled status before active status', () => {
|
||||
mocks.isActiveSubscription.value = true
|
||||
mocks.isCancelled.value = true
|
||||
|
||||
const billing = useLegacyBilling()
|
||||
|
||||
expect(billing.subscriptionStatus.value).toBe('canceled')
|
||||
})
|
||||
|
||||
it('initializes once and re-fetches zero free-tier balance', async () => {
|
||||
mocks.subscriptionTier.value = 'FREE'
|
||||
mocks.balance.value = { amount_micros: 0 }
|
||||
const billing = useLegacyBilling()
|
||||
|
||||
await billing.initialize()
|
||||
await billing.initialize()
|
||||
|
||||
expect(billing.isInitialized.value).toBe(true)
|
||||
expect(mocks.fetchStatus).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.fetchBalance).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('stores initialization error messages from Error failures', async () => {
|
||||
mocks.fetchStatus.mockRejectedValue(new Error('status failed'))
|
||||
const billing = useLegacyBilling()
|
||||
|
||||
await expect(billing.initialize()).rejects.toThrow('status failed')
|
||||
|
||||
expect(billing.error.value).toBe('status failed')
|
||||
expect(billing.isLoading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('stores fallback initialization error messages for non-Error failures', async () => {
|
||||
mocks.fetchStatus.mockRejectedValue('status failed')
|
||||
const billing = useLegacyBilling()
|
||||
|
||||
await expect(billing.initialize()).rejects.toBe('status failed')
|
||||
|
||||
expect(billing.error.value).toBe('Failed to initialize billing')
|
||||
})
|
||||
|
||||
it('stores subscription fetch fallback errors', async () => {
|
||||
mocks.fetchStatus.mockRejectedValue('status failed')
|
||||
const billing = useLegacyBilling()
|
||||
|
||||
await expect(billing.fetchStatus()).rejects.toBe('status failed')
|
||||
|
||||
expect(billing.error.value).toBe('Failed to fetch subscription')
|
||||
expect(billing.isLoading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('stores balance fetch errors', async () => {
|
||||
mocks.fetchBalance.mockRejectedValue(new Error('balance failed'))
|
||||
const billing = useLegacyBilling()
|
||||
|
||||
await expect(billing.fetchBalance()).rejects.toThrow('balance failed')
|
||||
|
||||
expect(billing.error.value).toBe('balance failed')
|
||||
expect(billing.isLoading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('stores balance fetch fallback errors', async () => {
|
||||
mocks.fetchBalance.mockRejectedValue('balance failed')
|
||||
const billing = useLegacyBilling()
|
||||
|
||||
await expect(billing.fetchBalance()).rejects.toBe('balance failed')
|
||||
|
||||
expect(billing.error.value).toBe('Failed to fetch balance')
|
||||
})
|
||||
|
||||
it('delegates legacy billing actions', async () => {
|
||||
const billing = useLegacyBilling()
|
||||
|
||||
await expect(billing.subscribe('pro-monthly')).resolves.toBeUndefined()
|
||||
await expect(billing.previewSubscribe('pro-monthly')).resolves.toBeNull()
|
||||
await billing.manageSubscription()
|
||||
await billing.cancelSubscription()
|
||||
await billing.resubscribe()
|
||||
await billing.topup(750)
|
||||
await expect(billing.fetchPlans()).resolves.toBeUndefined()
|
||||
billing.showSubscriptionDialog()
|
||||
|
||||
expect(mocks.subscribe).toHaveBeenCalledTimes(2)
|
||||
expect(mocks.manageSubscription).toHaveBeenCalledTimes(2)
|
||||
expect(mocks.purchaseCredits).toHaveBeenCalledWith(7.5)
|
||||
expect(mocks.showSubscriptionDialog).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('shows the subscription dialog when active subscription is required', async () => {
|
||||
const billing = useLegacyBilling()
|
||||
|
||||
await billing.requireActiveSubscription()
|
||||
|
||||
expect(mocks.showSubscriptionDialog).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not show the subscription dialog for active subscribers', async () => {
|
||||
mocks.isActiveSubscription.value = true
|
||||
const billing = useLegacyBilling()
|
||||
|
||||
await billing.requireActiveSubscription()
|
||||
|
||||
expect(mocks.showSubscriptionDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,217 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, defineComponent, ref } from 'vue'
|
||||
|
||||
interface MockTerminalInstance {
|
||||
cols: number
|
||||
rows: number
|
||||
options: unknown
|
||||
loadAddon: ReturnType<typeof vi.fn>
|
||||
attachCustomKeyEventHandler: ReturnType<typeof vi.fn>
|
||||
open: ReturnType<typeof vi.fn>
|
||||
dispose: ReturnType<typeof vi.fn>
|
||||
resize: ReturnType<typeof vi.fn>
|
||||
hasSelection: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
interface MockFitAddonInstance {
|
||||
proposeDimensions: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
const mockXterm = vi.hoisted(() => {
|
||||
const terminalInstances: MockTerminalInstance[] = []
|
||||
const fitAddonInstances: MockFitAddonInstance[] = []
|
||||
|
||||
class Terminal {
|
||||
cols = 80
|
||||
rows = 24
|
||||
loadAddon = vi.fn()
|
||||
attachCustomKeyEventHandler = vi.fn()
|
||||
open = vi.fn()
|
||||
dispose = vi.fn()
|
||||
resize = vi.fn((cols: number, rows: number) => {
|
||||
this.cols = cols
|
||||
this.rows = rows
|
||||
})
|
||||
hasSelection = vi.fn(() => false)
|
||||
|
||||
constructor(readonly options: unknown) {
|
||||
terminalInstances.push(this)
|
||||
}
|
||||
}
|
||||
|
||||
class FitAddon {
|
||||
proposeDimensions = vi.fn(() => ({ cols: 120, rows: 40 }))
|
||||
|
||||
constructor() {
|
||||
fitAddonInstances.push(this)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
Terminal,
|
||||
FitAddon,
|
||||
terminalInstances,
|
||||
fitAddonInstances
|
||||
}
|
||||
})
|
||||
|
||||
const mockResizeObserverInstances = [] as MockResizeObserver[]
|
||||
|
||||
class MockResizeObserver {
|
||||
observe = vi.fn()
|
||||
disconnect = vi.fn()
|
||||
|
||||
constructor(readonly callback: ResizeObserverCallback) {
|
||||
mockResizeObserverInstances.push(this)
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('@xterm/xterm', () => ({
|
||||
Terminal: mockXterm.Terminal
|
||||
}))
|
||||
|
||||
vi.mock('@xterm/addon-fit', () => ({
|
||||
FitAddon: mockXterm.FitAddon
|
||||
}))
|
||||
|
||||
vi.mock('es-toolkit/compat', () => ({
|
||||
debounce: (fn: () => void) => fn
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isDesktop: true
|
||||
}))
|
||||
|
||||
import { useTerminal } from './useTerminal'
|
||||
|
||||
function terminalElement() {
|
||||
const element = document.createElement('div')
|
||||
Object.defineProperty(element, 'clientWidth', { value: 160 })
|
||||
Object.defineProperty(element, 'clientHeight', { value: 100 })
|
||||
return element
|
||||
}
|
||||
|
||||
function mountTerminal(
|
||||
configure?: (
|
||||
result: ReturnType<typeof useTerminal>,
|
||||
root: ReturnType<typeof ref<HTMLElement | undefined>>
|
||||
) => void
|
||||
) {
|
||||
let result: ReturnType<typeof useTerminal> | undefined
|
||||
const root = ref<HTMLElement | undefined>(terminalElement())
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup() {
|
||||
result = useTerminal(root)
|
||||
configure?.(result, root)
|
||||
return () => null
|
||||
}
|
||||
})
|
||||
)
|
||||
app.mount(document.createElement('div'))
|
||||
if (!result) throw new Error('Expected terminal composable to initialize')
|
||||
return { app, result, root }
|
||||
}
|
||||
|
||||
describe('useTerminal', () => {
|
||||
beforeEach(() => {
|
||||
mockXterm.terminalInstances.length = 0
|
||||
mockXterm.fitAddonInstances.length = 0
|
||||
mockResizeObserverInstances.length = 0
|
||||
vi.stubGlobal('ResizeObserver', MockResizeObserver)
|
||||
})
|
||||
|
||||
it('creates a desktop themed terminal and opens it on mount', () => {
|
||||
const { app, root } = mountTerminal()
|
||||
const terminal = mockXterm.terminalInstances[0]
|
||||
const fitAddon = mockXterm.fitAddonInstances[0]
|
||||
|
||||
expect(terminal.options).toMatchObject({
|
||||
convertEol: true,
|
||||
theme: { background: '#171717' }
|
||||
})
|
||||
expect(terminal.loadAddon).toHaveBeenCalledWith(fitAddon)
|
||||
expect(terminal.open).toHaveBeenCalledWith(root.value)
|
||||
|
||||
app.unmount()
|
||||
expect(terminal.dispose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('lets browser copy and paste shortcuts pass through', () => {
|
||||
mountTerminal()
|
||||
const terminal = mockXterm.terminalInstances[0]
|
||||
const handler = terminal.attachCustomKeyEventHandler.mock.calls[0][0] as (
|
||||
event: KeyboardEvent
|
||||
) => boolean
|
||||
|
||||
terminal.hasSelection.mockReturnValue(true)
|
||||
expect(
|
||||
handler(new KeyboardEvent('keydown', { key: 'c', ctrlKey: true }))
|
||||
).toBe(false)
|
||||
expect(
|
||||
handler(new KeyboardEvent('keydown', { key: 'v', metaKey: true }))
|
||||
).toBe(false)
|
||||
|
||||
terminal.hasSelection.mockReturnValue(false)
|
||||
expect(
|
||||
handler(new KeyboardEvent('keydown', { key: 'c', ctrlKey: true }))
|
||||
).toBe(true)
|
||||
expect(
|
||||
handler(new KeyboardEvent('keyup', { key: 'v', ctrlKey: true }))
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('auto-sizes from fit dimensions and disconnects the observer on unmount', () => {
|
||||
const onResize = vi.fn()
|
||||
const { app, root } = mountTerminal((terminal, rootRef) => {
|
||||
terminal.useAutoSize({
|
||||
root: rootRef,
|
||||
minCols: 100,
|
||||
minRows: 20,
|
||||
onResize
|
||||
})
|
||||
})
|
||||
const terminal = mockXterm.terminalInstances[0]
|
||||
const observer = mockResizeObserverInstances[0]
|
||||
|
||||
expect(observer.observe).toHaveBeenCalledWith(root.value)
|
||||
expect(terminal.resize).toHaveBeenCalledWith(120, 40)
|
||||
expect(onResize).toHaveBeenCalledOnce()
|
||||
|
||||
app.unmount()
|
||||
expect(observer.disconnect).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('estimates invalid fit dimensions from the root element', () => {
|
||||
const { result, root } = mountTerminal()
|
||||
const fitAddon = mockXterm.fitAddonInstances[0]
|
||||
fitAddon.proposeDimensions.mockReturnValue({
|
||||
cols: Number.NaN,
|
||||
rows: undefined
|
||||
})
|
||||
const { resize } = result.useAutoSize({ root, minCols: 30, minRows: 10 })
|
||||
const terminal = mockXterm.terminalInstances[0]
|
||||
|
||||
resize()
|
||||
|
||||
expect(terminal.resize).toHaveBeenLastCalledWith(30, 10)
|
||||
})
|
||||
|
||||
it('keeps existing terminal dimensions when auto sizing is disabled', () => {
|
||||
const { result, root } = mountTerminal()
|
||||
const terminal = mockXterm.terminalInstances[0]
|
||||
terminal.cols = 90
|
||||
terminal.rows = 30
|
||||
const { resize } = result.useAutoSize({
|
||||
root,
|
||||
autoCols: false,
|
||||
autoRows: false,
|
||||
minCols: 10,
|
||||
minRows: 10
|
||||
})
|
||||
|
||||
resize()
|
||||
|
||||
expect(terminal.resize).toHaveBeenLastCalledWith(90, 30)
|
||||
})
|
||||
})
|
||||
@@ -5,7 +5,6 @@ import type { Ref, ShallowRef } from 'vue'
|
||||
import { defineComponent, h, nextTick, ref, shallowRef } from 'vue'
|
||||
|
||||
import { useBoundingBoxes } from './useBoundingBoxes'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import type { BoundingBox } from '@/types/boundingBoxes'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
@@ -36,26 +35,11 @@ const ctx = {
|
||||
lineWidth: 0
|
||||
} as unknown as CanvasRenderingContext2D
|
||||
|
||||
function makeCanvas(
|
||||
options: {
|
||||
context?: CanvasRenderingContext2D | null
|
||||
clientWidth?: number
|
||||
clientHeight?: number
|
||||
} = {}
|
||||
): HTMLCanvasElement {
|
||||
function makeCanvas(): HTMLCanvasElement {
|
||||
const el = document.createElement('canvas')
|
||||
Object.defineProperty(el, 'clientWidth', {
|
||||
value: options.clientWidth ?? 100,
|
||||
configurable: true
|
||||
})
|
||||
Object.defineProperty(el, 'clientHeight', {
|
||||
value: options.clientHeight ?? 100,
|
||||
configurable: true
|
||||
})
|
||||
el.getContext = (() =>
|
||||
options.context === undefined
|
||||
? ctx
|
||||
: options.context) as unknown as HTMLCanvasElement['getContext']
|
||||
Object.defineProperty(el, 'clientWidth', { value: 100, configurable: true })
|
||||
Object.defineProperty(el, 'clientHeight', { value: 100, configurable: true })
|
||||
el.getContext = (() => ctx) as unknown as HTMLCanvasElement['getContext']
|
||||
el.getBoundingClientRect = () =>
|
||||
({
|
||||
left: 0,
|
||||
@@ -112,14 +96,14 @@ interface Captured extends Api {
|
||||
modelValue: Ref<BoundingBox[]>
|
||||
}
|
||||
|
||||
function setup(initial: BoundingBox[] | undefined = []) {
|
||||
function setup(initial: BoundingBox[] = []) {
|
||||
let captured: Captured | undefined
|
||||
const Harness = defineComponent({
|
||||
setup() {
|
||||
const canvasEl = shallowRef<HTMLCanvasElement | null>(null)
|
||||
const canvasContainer = shallowRef<HTMLDivElement | null>(null)
|
||||
const inlineEditorEl = shallowRef<HTMLTextAreaElement | null>(null)
|
||||
const modelValue = ref(initial as BoundingBox[])
|
||||
const modelValue = ref(initial)
|
||||
const api = useBoundingBoxes(toNodeId('1'), {
|
||||
canvasEl,
|
||||
canvasContainer,
|
||||
@@ -175,43 +159,9 @@ describe('useBoundingBoxes initialization', () => {
|
||||
expect(c.hasRegions.value).toBe(false)
|
||||
expect(c.activeRegion.value).toBeNull()
|
||||
})
|
||||
|
||||
it('falls back to default dimensions when the litegraph node is unavailable', () => {
|
||||
appState.node = null
|
||||
const c = setup([box()])
|
||||
expect(c.canvasStyle.value).toEqual({ aspectRatio: '1024 / 1024' })
|
||||
})
|
||||
|
||||
it('ignores non-positive dimension widgets', () => {
|
||||
appState.node = {
|
||||
widgets: [
|
||||
{ name: 'width', value: 0 },
|
||||
{ name: 'height', value: 'bad' }
|
||||
],
|
||||
findInputSlot: () => -1,
|
||||
getInputNode: () => null
|
||||
}
|
||||
const c = setup()
|
||||
expect(c.canvasStyle.value).toEqual({ aspectRatio: '1024 / 1024' })
|
||||
})
|
||||
|
||||
it('treats an undefined model value as empty', () => {
|
||||
const c = setup(undefined)
|
||||
expect(c.hasRegions.value).toBe(false)
|
||||
expect(c.modelValue.value).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('useBoundingBoxes drawing', () => {
|
||||
it('ignores non-primary pointer buttons', async () => {
|
||||
const c = setup()
|
||||
c.onPointerDown(pe(10, 10, { button: 1 }))
|
||||
c.onCanvasPointerMove(pe(60, 60))
|
||||
c.onDocPointerUp(pe(60, 60))
|
||||
await flush()
|
||||
expect(c.modelValue.value).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('draws a new region and syncs it to the model value', async () => {
|
||||
const c = setup()
|
||||
c.onPointerDown(pe(10, 10))
|
||||
@@ -237,102 +187,6 @@ describe('useBoundingBoxes drawing', () => {
|
||||
await flush()
|
||||
expect(c.modelValue.value).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('moves an existing active region by dragging inside it', async () => {
|
||||
const c = setup([box()])
|
||||
c.onPointerDown(pe(30, 30))
|
||||
c.onCanvasPointerMove(pe(45, 50))
|
||||
c.onDocPointerUp(pe(45, 50))
|
||||
await flush()
|
||||
expect(c.modelValue.value[0].x).toBeGreaterThan(51)
|
||||
expect(c.modelValue.value[0].y).toBeGreaterThan(51)
|
||||
})
|
||||
|
||||
it('resizes an existing active region from its corner handle', async () => {
|
||||
const c = setup([box()])
|
||||
c.onPointerDown(pe(60, 60))
|
||||
c.onCanvasPointerMove(pe(80, 80))
|
||||
c.onDocPointerUp(pe(80, 80))
|
||||
await flush()
|
||||
expect(c.modelValue.value[0].width).toBeGreaterThan(256)
|
||||
expect(c.modelValue.value[0].height).toBeGreaterThan(256)
|
||||
})
|
||||
|
||||
it('keeps selection valid when Alt-clicking overlapping regions', async () => {
|
||||
const c = setup([
|
||||
box(),
|
||||
box({
|
||||
metadata: {
|
||||
type: 'obj',
|
||||
text: '',
|
||||
desc: 'second',
|
||||
palette: ['#ff0000']
|
||||
}
|
||||
})
|
||||
])
|
||||
|
||||
c.onPointerDown(pe(30, 30, { altKey: true }))
|
||||
c.onDocPointerUp(pe(30, 30))
|
||||
await flush()
|
||||
|
||||
expect(c.activeRegion.value).not.toBeNull()
|
||||
expect(c.modelValue.value).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('ignores document movement and pointer up when no draw is active', async () => {
|
||||
const c = setup([box()])
|
||||
|
||||
c.onCanvasPointerMove(pe(5, 95))
|
||||
c.onDocPointerUp(pe(95, 95))
|
||||
await flush()
|
||||
|
||||
expect(c.modelValue.value).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('uses zero pointer coordinates when the canvas is unavailable', async () => {
|
||||
const c = setup()
|
||||
c.canvasEl.value = null
|
||||
|
||||
c.onPointerDown(pe(50, 50))
|
||||
c.onCanvasPointerMove(pe(80, 80))
|
||||
c.onDocPointerUp(pe(80, 80))
|
||||
await flush()
|
||||
|
||||
expect(c.modelValue.value).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('redraws active text regions with fallback palette color', async () => {
|
||||
const c = setup([
|
||||
box({
|
||||
x: 10,
|
||||
y: 10,
|
||||
width: 30,
|
||||
height: 30,
|
||||
metadata: {
|
||||
type: 'text',
|
||||
text: 'hello',
|
||||
desc: 'alpha beta\n\ncharlie',
|
||||
palette: []
|
||||
}
|
||||
})
|
||||
])
|
||||
|
||||
c.focused.value = true
|
||||
c.syncState()
|
||||
await flush()
|
||||
|
||||
expect(c.modelValue.value).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('draws safely when the canvas context is unavailable', async () => {
|
||||
const c = setup([box()])
|
||||
c.canvasEl.value = makeCanvas({ context: null })
|
||||
|
||||
c.syncState()
|
||||
await flush()
|
||||
|
||||
expect(c.modelValue.value).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useBoundingBoxes region editing', () => {
|
||||
@@ -360,60 +214,6 @@ describe('useBoundingBoxes region editing', () => {
|
||||
await flush()
|
||||
expect(c.modelValue.value).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does nothing when changing type without an active region', async () => {
|
||||
const c = setup()
|
||||
c.setActiveType('text')
|
||||
await flush()
|
||||
expect(c.modelValue.value).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('deletes the active region on Backspace', async () => {
|
||||
const c = setup([box()])
|
||||
c.onCanvasKeyDown({
|
||||
key: 'Backspace',
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {}
|
||||
} as unknown as KeyboardEvent)
|
||||
await flush()
|
||||
expect(c.modelValue.value).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('ignores unrelated keys and key events while drawing', async () => {
|
||||
const c = setup([box()])
|
||||
c.onCanvasKeyDown({
|
||||
key: 'Enter',
|
||||
preventDefault: () => {
|
||||
throw new Error('should not prevent')
|
||||
},
|
||||
stopPropagation: () => {}
|
||||
} as unknown as KeyboardEvent)
|
||||
c.onPointerDown(pe(80, 80))
|
||||
c.onCanvasKeyDown({
|
||||
key: 'Delete',
|
||||
preventDefault: () => {
|
||||
throw new Error('should not prevent while drawing')
|
||||
},
|
||||
stopPropagation: () => {}
|
||||
} as unknown as KeyboardEvent)
|
||||
c.onDocPointerUp(pe(80, 80))
|
||||
await flush()
|
||||
expect(c.modelValue.value).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('keeps a remaining region selected after deleting from a multi-region list', async () => {
|
||||
const c = setup([box(), box({ x: 10 })])
|
||||
|
||||
c.onCanvasKeyDown({
|
||||
key: 'Delete',
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {}
|
||||
} as unknown as KeyboardEvent)
|
||||
await flush()
|
||||
|
||||
expect(c.modelValue.value).toHaveLength(1)
|
||||
expect(c.activeRegion.value).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useBoundingBoxes inline editor', () => {
|
||||
@@ -437,86 +237,6 @@ describe('useBoundingBoxes inline editor', () => {
|
||||
c.onInlineKeyDown({ key: 'Escape' } as KeyboardEvent)
|
||||
expect(c.inlineEditor.value).toBeNull()
|
||||
})
|
||||
|
||||
it('commits the inline editor on Ctrl+Enter', async () => {
|
||||
const c = setup([box()])
|
||||
c.onDoubleClick(pe(30, 30) as unknown as MouseEvent)
|
||||
await flush()
|
||||
c.inlineEditor.value!.value = 'committed'
|
||||
c.onInlineKeyDown({
|
||||
key: 'Enter',
|
||||
ctrlKey: true,
|
||||
metaKey: false
|
||||
} as KeyboardEvent)
|
||||
await flush()
|
||||
expect(c.modelValue.value[0].metadata.desc).toBe('committed')
|
||||
})
|
||||
|
||||
it('commits the inline editor on Meta+Enter', async () => {
|
||||
const c = setup([box()])
|
||||
c.onDoubleClick(pe(30, 30) as unknown as MouseEvent)
|
||||
await flush()
|
||||
c.inlineEditor.value!.value = 'meta committed'
|
||||
c.onInlineKeyDown({
|
||||
key: 'Enter',
|
||||
ctrlKey: false,
|
||||
metaKey: true
|
||||
} as KeyboardEvent)
|
||||
await flush()
|
||||
expect(c.modelValue.value[0].metadata.desc).toBe('meta committed')
|
||||
})
|
||||
|
||||
it('ignores Enter without a modifier in the inline editor', async () => {
|
||||
const c = setup([box()])
|
||||
c.onDoubleClick(pe(30, 30) as unknown as MouseEvent)
|
||||
await flush()
|
||||
c.inlineEditor.value!.value = 'not committed'
|
||||
c.onInlineKeyDown({
|
||||
key: 'Enter',
|
||||
ctrlKey: false,
|
||||
metaKey: false
|
||||
} as KeyboardEvent)
|
||||
await flush()
|
||||
expect(c.modelValue.value[0].metadata.desc).toBe('')
|
||||
})
|
||||
|
||||
it('leaves state unchanged when committing without an editor', async () => {
|
||||
const c = setup([box()])
|
||||
c.commitInlineEditor()
|
||||
await flush()
|
||||
expect(c.modelValue.value[0].metadata.desc).toBe('')
|
||||
})
|
||||
|
||||
it('closes a stale inline editor after its region was removed', async () => {
|
||||
const c = setup([box()])
|
||||
c.onDoubleClick(pe(30, 30) as unknown as MouseEvent)
|
||||
await flush()
|
||||
c.inlineEditor.value!.value = 'stale'
|
||||
|
||||
c.clearAll()
|
||||
c.commitInlineEditor()
|
||||
await flush()
|
||||
|
||||
expect(c.inlineEditor.value).toBeNull()
|
||||
expect(c.modelValue.value).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does not open the inline editor when double-clicking empty space', async () => {
|
||||
const c = setup([box({ x: 0, y: 0, width: 50, height: 50 })])
|
||||
c.onDoubleClick(pe(95, 95) as unknown as MouseEvent)
|
||||
await flush()
|
||||
expect(c.inlineEditor.value).toBeNull()
|
||||
})
|
||||
|
||||
it('uses zero mouse coordinates when double-clicking without a canvas', async () => {
|
||||
const c = setup([box({ x: 0, y: 0, width: 512, height: 512 })])
|
||||
c.canvasEl.value = null
|
||||
|
||||
c.onDoubleClick(pe(30, 30) as unknown as MouseEvent)
|
||||
await flush()
|
||||
|
||||
expect(c.inlineEditor.value).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useBoundingBoxes hover cursor', () => {
|
||||
@@ -527,74 +247,4 @@ describe('useBoundingBoxes hover cursor', () => {
|
||||
await flush()
|
||||
expect(c.canvasCursor.value).toBe('pointer')
|
||||
})
|
||||
|
||||
it('returns to the default cursor after leaving the canvas', async () => {
|
||||
const c = setup([box({ x: 10, y: 10, width: 256, height: 256 })])
|
||||
c.onCanvasPointerMove(pe(15, 15))
|
||||
await flush()
|
||||
c.onPointerLeave()
|
||||
await flush()
|
||||
expect(c.canvasCursor.value).toBe('crosshair')
|
||||
})
|
||||
|
||||
it('does nothing when leaving without hover state', async () => {
|
||||
const c = setup([box()])
|
||||
c.onPointerLeave()
|
||||
await flush()
|
||||
expect(c.canvasCursor.value).toBe('crosshair')
|
||||
})
|
||||
|
||||
it('keeps cursor default when canvas context is unavailable for title hit testing', async () => {
|
||||
const c = setup([box()])
|
||||
c.canvasEl.value = makeCanvas({ context: null })
|
||||
c.onCanvasPointerMove(pe(30, 30))
|
||||
await flush()
|
||||
expect(c.canvasCursor.value).toBe('crosshair')
|
||||
})
|
||||
|
||||
it('keeps hover state unchanged when pointer movement hits the same tag', async () => {
|
||||
const c = setup([box({ x: 10, y: 10, width: 256, height: 256 })])
|
||||
|
||||
c.onCanvasPointerMove(pe(15, 15))
|
||||
await flush()
|
||||
c.onCanvasPointerMove(pe(15, 15))
|
||||
await flush()
|
||||
|
||||
expect(c.canvasCursor.value).toBe('pointer')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useBoundingBoxes background image', () => {
|
||||
it('loads a background image and snaps node dimensions', async () => {
|
||||
const widthCallback = vi.fn()
|
||||
const heightCallback = vi.fn()
|
||||
const inputNode = { id: 2 }
|
||||
appState.node = {
|
||||
widgets: [
|
||||
{ name: 'width', value: 512, callback: widthCallback },
|
||||
{ name: 'height', value: 512, callback: heightCallback }
|
||||
],
|
||||
findInputSlot: () => 0,
|
||||
getInputNode: () => inputNode
|
||||
}
|
||||
const store = useNodeOutputStore()
|
||||
vi.spyOn(store, 'getNodeImageUrls').mockReturnValue(['blob:bg'])
|
||||
class FakeImage {
|
||||
crossOrigin = ''
|
||||
naturalWidth = 257
|
||||
naturalHeight = 271
|
||||
onload: (() => void) | null = null
|
||||
|
||||
set src(_value: string) {
|
||||
this.onload?.()
|
||||
}
|
||||
}
|
||||
vi.stubGlobal('Image', FakeImage)
|
||||
|
||||
setup([box()])
|
||||
await flush()
|
||||
|
||||
expect(widthCallback).toHaveBeenCalledWith(256)
|
||||
expect(heightCallback).toHaveBeenCalledWith(272)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useFocusNode } from '@/composables/canvas/useFocusNode'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
type Graph = {
|
||||
isRootGraph: boolean
|
||||
}
|
||||
|
||||
type FocusableNode = {
|
||||
graph?: Graph
|
||||
boundingRect: DOMRect
|
||||
}
|
||||
|
||||
const { appState, canvasStore, getNodeByExecutionId } = vi.hoisted(() => ({
|
||||
appState: {
|
||||
rootGraph: { isRootGraph: true }
|
||||
},
|
||||
canvasStore: {
|
||||
canvas: undefined as
|
||||
| undefined
|
||||
| {
|
||||
graph: Graph
|
||||
subgraph?: Graph
|
||||
setGraph: ReturnType<typeof vi.fn>
|
||||
animateToBounds: ReturnType<typeof vi.fn>
|
||||
}
|
||||
},
|
||||
getNodeByExecutionId: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => canvasStore
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: appState
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/graphTraversalUtil', () => ({
|
||||
getNodeByExecutionId
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
getNodeByExecutionId.mockReset()
|
||||
vi.stubGlobal(
|
||||
'requestAnimationFrame',
|
||||
(callback: FrameRequestCallback): number => {
|
||||
callback(0)
|
||||
return 1
|
||||
}
|
||||
)
|
||||
canvasStore.canvas = {
|
||||
graph: appState.rootGraph,
|
||||
setGraph: vi.fn(),
|
||||
animateToBounds: vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
describe('useFocusNode', () => {
|
||||
it('does nothing when there is no canvas or matching graph node', async () => {
|
||||
canvasStore.canvas = undefined
|
||||
await useFocusNode().focusNode('node-1')
|
||||
|
||||
expect(getNodeByExecutionId).not.toHaveBeenCalled()
|
||||
|
||||
canvasStore.canvas = {
|
||||
graph: appState.rootGraph,
|
||||
setGraph: vi.fn(),
|
||||
animateToBounds: vi.fn()
|
||||
}
|
||||
getNodeByExecutionId.mockReturnValue({ boundingRect: new DOMRect() })
|
||||
|
||||
await useFocusNode().focusNode('node-1')
|
||||
|
||||
expect(canvasStore.canvas.animateToBounds).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('navigates to the node graph before focusing its bounds', async () => {
|
||||
const subgraph = { isRootGraph: false }
|
||||
const bounds = new DOMRect(1, 2, 3, 4)
|
||||
getNodeByExecutionId.mockReturnValue({
|
||||
graph: subgraph,
|
||||
boundingRect: bounds
|
||||
} satisfies FocusableNode)
|
||||
|
||||
await useFocusNode().focusNode('node-1')
|
||||
|
||||
expect(getNodeByExecutionId).toHaveBeenCalledWith(
|
||||
appState.rootGraph,
|
||||
'node-1'
|
||||
)
|
||||
expect(canvasStore.canvas?.subgraph).toBe(subgraph)
|
||||
expect(canvasStore.canvas?.setGraph).toHaveBeenCalledWith(subgraph)
|
||||
expect(canvasStore.canvas?.animateToBounds).toHaveBeenCalledWith(bounds)
|
||||
})
|
||||
|
||||
it('uses an execution id map and skips graph navigation when already there', async () => {
|
||||
const graph = { isRootGraph: true }
|
||||
const bounds = new DOMRect(5, 6, 7, 8)
|
||||
canvasStore.canvas = {
|
||||
graph,
|
||||
setGraph: vi.fn(),
|
||||
animateToBounds: vi.fn()
|
||||
}
|
||||
const node = { graph, boundingRect: bounds } satisfies FocusableNode
|
||||
|
||||
await useFocusNode().focusNode(
|
||||
'node-1',
|
||||
new Map([['node-1', fromAny<LGraphNode, unknown>(node)]])
|
||||
)
|
||||
|
||||
expect(getNodeByExecutionId).not.toHaveBeenCalled()
|
||||
expect(canvasStore.canvas.setGraph).not.toHaveBeenCalled()
|
||||
expect(canvasStore.canvas.animateToBounds).toHaveBeenCalledWith(bounds)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { render } from '@testing-library/vue'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { defineComponent, h, markRaw, nextTick, ref } from 'vue'
|
||||
import { defineComponent, h, markRaw, ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useSelectionToolboxPosition } from '@/composables/canvas/useSelectionToolboxPosition'
|
||||
@@ -12,35 +12,19 @@ import {
|
||||
LiteGraph
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import { createMockPositionable } from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
const mockApp = vi.hoisted(() => ({
|
||||
canvas: null
|
||||
}))
|
||||
|
||||
const mockFeatureFlags = vi.hoisted(() => ({
|
||||
refs: null as null | {
|
||||
shouldRenderVueNodes: { value: boolean }
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({ app: mockApp }))
|
||||
|
||||
vi.mock('@/composables/useVueFeatureFlags', async () => {
|
||||
const { ref } = await import('vue')
|
||||
const shouldRenderVueNodes = ref(false)
|
||||
mockFeatureFlags.refs = {
|
||||
shouldRenderVueNodes
|
||||
}
|
||||
|
||||
return {
|
||||
useVueFeatureFlags: () => ({
|
||||
shouldRenderVueNodes
|
||||
})
|
||||
}
|
||||
})
|
||||
vi.mock('@/composables/useVueFeatureFlags', () => ({
|
||||
useVueFeatureFlags: () => ({
|
||||
shouldRenderVueNodes: { value: false }
|
||||
})
|
||||
}))
|
||||
|
||||
describe('useSelectionToolboxPosition', () => {
|
||||
let canvasStore: ReturnType<typeof useCanvasStore>
|
||||
@@ -48,39 +32,28 @@ describe('useSelectionToolboxPosition', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
canvasStore = useCanvasStore()
|
||||
layoutStore.initializeFromLiteGraph([])
|
||||
layoutStore.isDraggingVueNodes.value = false
|
||||
if (mockFeatureFlags.refs) {
|
||||
mockFeatureFlags.refs.shouldRenderVueNodes.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function renderToolboxForSelection(
|
||||
items: Iterable<Positionable>,
|
||||
state: Partial<LGraphCanvas['state']> = {},
|
||||
ds: Partial<LGraphCanvas['ds']> = {}
|
||||
) {
|
||||
function renderToolboxForSelection(item: Positionable) {
|
||||
canvasStore.canvas = markRaw({
|
||||
canvas: document.createElement('canvas'),
|
||||
ds: {
|
||||
offset: ds.offset ?? [0, 0],
|
||||
scale: ds.scale ?? 1
|
||||
offset: [0, 0],
|
||||
scale: 1
|
||||
},
|
||||
selectedItems: new Set(items),
|
||||
selectedItems: new Set([item]),
|
||||
state: {
|
||||
draggingItems: false,
|
||||
selectionChanged: true,
|
||||
...state
|
||||
selectionChanged: true
|
||||
}
|
||||
} as Partial<LGraphCanvas> as LGraphCanvas)
|
||||
|
||||
let toolbox: HTMLElement | undefined
|
||||
let visible!: ReturnType<typeof useSelectionToolboxPosition>['visible']
|
||||
const TestHarness = defineComponent({
|
||||
setup() {
|
||||
const toolboxRef = ref<HTMLElement>(document.createElement('div'))
|
||||
toolbox = toolboxRef.value
|
||||
;({ visible } = useSelectionToolboxPosition(toolboxRef))
|
||||
useSelectionToolboxPosition(toolboxRef)
|
||||
return () => h('div')
|
||||
}
|
||||
})
|
||||
@@ -88,28 +61,7 @@ describe('useSelectionToolboxPosition', () => {
|
||||
const wrapper = render(TestHarness)
|
||||
if (!toolbox) throw new Error('Toolbox element was not initialized')
|
||||
|
||||
if (!visible) throw new Error('Visible state was not initialized')
|
||||
|
||||
return { toolbox, unmount: wrapper.unmount, visible }
|
||||
}
|
||||
|
||||
function setCanvasSelection(
|
||||
items: Iterable<Positionable>,
|
||||
state: Partial<LGraphCanvas['state']> = {}
|
||||
) {
|
||||
canvasStore.canvas = markRaw({
|
||||
canvas: document.createElement('canvas'),
|
||||
ds: {
|
||||
offset: [0, 0],
|
||||
scale: 1
|
||||
},
|
||||
selectedItems: new Set(items),
|
||||
state: {
|
||||
draggingItems: false,
|
||||
selectionChanged: true,
|
||||
...state
|
||||
}
|
||||
} as Partial<LGraphCanvas> as LGraphCanvas)
|
||||
return { toolbox, unmount: wrapper.unmount }
|
||||
}
|
||||
|
||||
it('positions groups from their unchanged bounds', () => {
|
||||
@@ -117,7 +69,7 @@ describe('useSelectionToolboxPosition', () => {
|
||||
group.pos = [100, 200]
|
||||
group.size = [160, 80]
|
||||
|
||||
const { toolbox, unmount } = renderToolboxForSelection([group])
|
||||
const { toolbox, unmount } = renderToolboxForSelection(group)
|
||||
|
||||
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('190px')
|
||||
unmount()
|
||||
@@ -129,221 +81,11 @@ describe('useSelectionToolboxPosition', () => {
|
||||
node.pos = [100, 200]
|
||||
node.size = [160, 80]
|
||||
|
||||
const { toolbox, unmount } = renderToolboxForSelection([node])
|
||||
const { toolbox, unmount } = renderToolboxForSelection(node)
|
||||
|
||||
expect(toolbox.style.getPropertyValue('--tb-y')).toBe(
|
||||
`${190 - LiteGraph.NODE_TITLE_HEIGHT}px`
|
||||
)
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('does not set coordinates when selection is empty', () => {
|
||||
const { toolbox, unmount } = renderToolboxForSelection([])
|
||||
|
||||
expect(toolbox.style.getPropertyValue('--tb-x')).toBe('')
|
||||
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('')
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('does not update when selection state is unchanged', () => {
|
||||
const group = new LGraphGroup('Group', 1)
|
||||
group.pos = [100, 200]
|
||||
group.size = [160, 80]
|
||||
|
||||
const { toolbox, visible, unmount } = renderToolboxForSelection([group], {
|
||||
selectionChanged: false
|
||||
})
|
||||
|
||||
expect(visible.value).toBe(false)
|
||||
expect(toolbox.style.getPropertyValue('--tb-x')).toBe('')
|
||||
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('')
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('does not set coordinates while selected items are being dragged', () => {
|
||||
const group = new LGraphGroup('Group', 1)
|
||||
group.pos = [100, 200]
|
||||
group.size = [160, 80]
|
||||
|
||||
const { toolbox, unmount } = renderToolboxForSelection([group], {
|
||||
draggingItems: true
|
||||
})
|
||||
|
||||
expect(toolbox.style.getPropertyValue('--tb-x')).toBe('')
|
||||
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('')
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('positions multiple selected items from their union bounds', () => {
|
||||
const first = new LGraphGroup('First', 1)
|
||||
first.pos = [100, 200]
|
||||
first.size = [100, 40]
|
||||
const second = new LGraphGroup('Second', 2)
|
||||
second.pos = [300, 260]
|
||||
second.size = [50, 40]
|
||||
|
||||
const { toolbox, unmount } = renderToolboxForSelection([first, second])
|
||||
|
||||
expect(toolbox.style.getPropertyValue('--tb-x')).toBe('270px')
|
||||
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('190px')
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('applies canvas scale and offset to screen coordinates', () => {
|
||||
const group = new LGraphGroup('Group', 1)
|
||||
group.pos = [100, 200]
|
||||
group.size = [100, 40]
|
||||
|
||||
const { toolbox, unmount } = renderToolboxForSelection(
|
||||
[group],
|
||||
{},
|
||||
{ offset: [10, 20], scale: 2 }
|
||||
)
|
||||
|
||||
expect(toolbox.style.getPropertyValue('--tb-x')).toBe('360px')
|
||||
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('420px')
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('uses Vue layout bounds when Vue node rendering is enabled', () => {
|
||||
if (!mockFeatureFlags.refs) {
|
||||
throw new Error('feature flag refs were not initialized')
|
||||
}
|
||||
mockFeatureFlags.refs.shouldRenderVueNodes.value = true
|
||||
const node = new LGraphNode('Node')
|
||||
node.id = toNodeId(12)
|
||||
node.pos = [100, 200]
|
||||
node.size = [160, 80]
|
||||
layoutStore.initializeFromLiteGraph([
|
||||
{
|
||||
id: node.id,
|
||||
pos: [300, 400],
|
||||
size: [200, 120]
|
||||
}
|
||||
])
|
||||
|
||||
const { toolbox, unmount } = renderToolboxForSelection([node])
|
||||
|
||||
expect(toolbox.style.getPropertyValue('--tb-x')).toBe('400px')
|
||||
expect(toolbox.style.getPropertyValue('--tb-y')).toBe(
|
||||
`${390 - LiteGraph.NODE_TITLE_HEIGHT}px`
|
||||
)
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('falls back to LiteGraph node bounds when Vue layout is missing', () => {
|
||||
if (!mockFeatureFlags.refs) {
|
||||
throw new Error('feature flag refs were not initialized')
|
||||
}
|
||||
mockFeatureFlags.refs.shouldRenderVueNodes.value = true
|
||||
const node = new LGraphNode('Node')
|
||||
node.id = toNodeId(13)
|
||||
node.pos = [100, 200]
|
||||
node.size = [160, 80]
|
||||
|
||||
const { toolbox, unmount } = renderToolboxForSelection([node])
|
||||
|
||||
expect(toolbox.style.getPropertyValue('--tb-y')).toBe(
|
||||
`${190 - LiteGraph.NODE_TITLE_HEIGHT}px`
|
||||
)
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('hides the toolbox while Vue nodes are being dragged', () => {
|
||||
if (!mockFeatureFlags.refs) {
|
||||
throw new Error('feature flag refs were not initialized')
|
||||
}
|
||||
mockFeatureFlags.refs.shouldRenderVueNodes.value = true
|
||||
layoutStore.isDraggingVueNodes.value = true
|
||||
const group = new LGraphGroup('Group', 1)
|
||||
group.pos = [100, 200]
|
||||
group.size = [160, 80]
|
||||
|
||||
const { toolbox, unmount } = renderToolboxForSelection([group])
|
||||
|
||||
expect(toolbox.style.getPropertyValue('--tb-x')).toBe('')
|
||||
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('')
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('ignores selected items that are not nodes or groups', () => {
|
||||
const item = createMockPositionable({
|
||||
id: toNodeId(52),
|
||||
pos: [100, 200],
|
||||
size: [160, 80],
|
||||
boundingRect: [100, 200, 160, 80]
|
||||
})
|
||||
|
||||
const { toolbox, visible, unmount } = renderToolboxForSelection([item])
|
||||
|
||||
expect(visible.value).toBe(true)
|
||||
expect(toolbox.style.getPropertyValue('--tb-x')).toBe('')
|
||||
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('')
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('ignores selected items without valid ids', () => {
|
||||
const item = {
|
||||
id: null,
|
||||
pos: [100, 200],
|
||||
size: [160, 80],
|
||||
boundingRect: [100, 200, 160, 80]
|
||||
} as unknown as Positionable
|
||||
|
||||
const { toolbox, visible, unmount } = renderToolboxForSelection([item])
|
||||
|
||||
expect(visible.value).toBe(true)
|
||||
expect(toolbox.style.getPropertyValue('--tb-x')).toBe('')
|
||||
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('')
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('stays visible without mutating style when the toolbox ref is empty', () => {
|
||||
const group = new LGraphGroup('Group', 1)
|
||||
group.pos = [100, 200]
|
||||
group.size = [160, 80]
|
||||
setCanvasSelection([group])
|
||||
|
||||
let visible!: ReturnType<typeof useSelectionToolboxPosition>['visible']
|
||||
const TestHarness = defineComponent({
|
||||
setup() {
|
||||
;({ visible } = useSelectionToolboxPosition(ref()))
|
||||
return () => h('div')
|
||||
}
|
||||
})
|
||||
|
||||
const wrapper = render(TestHarness)
|
||||
|
||||
expect(visible.value).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('hides and restores around Vue node drag state changes', async () => {
|
||||
if (!mockFeatureFlags.refs) {
|
||||
throw new Error('feature flag refs were not initialized')
|
||||
}
|
||||
mockFeatureFlags.refs.shouldRenderVueNodes.value = true
|
||||
const group = new LGraphGroup('Group', 1)
|
||||
group.pos = [100, 200]
|
||||
group.size = [160, 80]
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
|
||||
window.setTimeout(() => callback(0), 0)
|
||||
)
|
||||
vi.stubGlobal('cancelAnimationFrame', (handle: number) => {
|
||||
clearTimeout(handle)
|
||||
})
|
||||
|
||||
const { visible, unmount } = renderToolboxForSelection([group])
|
||||
expect(visible.value).toBe(true)
|
||||
|
||||
layoutStore.isDraggingVueNodes.value = true
|
||||
await nextTick()
|
||||
expect(visible.value).toBe(false)
|
||||
|
||||
layoutStore.isDraggingVueNodes.value = false
|
||||
await nextTick()
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
expect(visible.value).toBe(true)
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
canvas: {
|
||||
canvas: {},
|
||||
ds: {
|
||||
scale: 3
|
||||
}
|
||||
},
|
||||
canvasPosToClientPos: vi.fn((pos: [number, number]) => [
|
||||
pos[0] + 10,
|
||||
pos[1] + 20
|
||||
]),
|
||||
getCanvas: vi.fn(),
|
||||
getSetting: vi.fn(),
|
||||
updateCanvasPosition: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({
|
||||
getCanvas: mocks.getCanvas
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: mocks.getSetting
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/element/useCanvasPositionConversion', () => ({
|
||||
useCanvasPositionConversion: vi.fn(() => ({
|
||||
canvasPosToClientPos: mocks.canvasPosToClientPos,
|
||||
update: mocks.updateCanvasPosition
|
||||
}))
|
||||
}))
|
||||
|
||||
const { useAbsolutePosition } = await import('./useAbsolutePosition')
|
||||
|
||||
describe('useAbsolutePosition', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.getCanvas.mockReturnValue(mocks.canvas)
|
||||
mocks.canvas.ds.scale = 3
|
||||
})
|
||||
|
||||
it('positions and scales an element with the canvas scale', () => {
|
||||
const { style, updatePosition } = useAbsolutePosition()
|
||||
|
||||
updatePosition({
|
||||
pos: [1, 2],
|
||||
size: [4, 5]
|
||||
})
|
||||
|
||||
expect(style.value).toMatchObject({
|
||||
position: 'fixed',
|
||||
left: '11px',
|
||||
top: '22px',
|
||||
width: '12px',
|
||||
height: '15px'
|
||||
})
|
||||
})
|
||||
|
||||
it('uses an explicit scale when provided', () => {
|
||||
const { style, updatePosition } = useAbsolutePosition()
|
||||
|
||||
updatePosition({
|
||||
pos: [1, 2],
|
||||
size: [4, 5],
|
||||
scale: 2
|
||||
})
|
||||
|
||||
expect(style.value).toMatchObject({
|
||||
width: '8px',
|
||||
height: '10px'
|
||||
})
|
||||
})
|
||||
|
||||
it('applies transform scaling without resizing the element bounds', () => {
|
||||
const { style, updatePosition } = useAbsolutePosition({
|
||||
useTransform: true
|
||||
})
|
||||
|
||||
updatePosition({
|
||||
pos: [1, 2],
|
||||
size: [4, 5],
|
||||
scale: 2
|
||||
})
|
||||
|
||||
expect(style.value).toMatchObject({
|
||||
position: 'fixed',
|
||||
transformOrigin: '0 0',
|
||||
transform: 'scale(2)',
|
||||
left: '11px',
|
||||
top: '22px',
|
||||
width: '4px',
|
||||
height: '5px'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,86 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const canvas = {
|
||||
canvas: {},
|
||||
ds: {
|
||||
offset: [10, 20],
|
||||
scale: 2
|
||||
}
|
||||
} as unknown as LGraphCanvas
|
||||
|
||||
return {
|
||||
bounds: {
|
||||
left: { value: 4 },
|
||||
top: { value: 6 }
|
||||
},
|
||||
canvas,
|
||||
getCanvas: vi.fn(() => canvas),
|
||||
update: vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
useElementBounding: vi.fn(() => ({
|
||||
left: mocks.bounds.left,
|
||||
top: mocks.bounds.top,
|
||||
update: mocks.update
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({
|
||||
getCanvas: mocks.getCanvas
|
||||
})
|
||||
}))
|
||||
|
||||
const { useCanvasPositionConversion, useSharedCanvasPositionConversion } =
|
||||
await import('./useCanvasPositionConversion')
|
||||
|
||||
describe('useCanvasPositionConversion', () => {
|
||||
beforeEach(() => {
|
||||
mocks.bounds.left.value = 4
|
||||
mocks.bounds.top.value = 6
|
||||
mocks.getCanvas.mockClear()
|
||||
mocks.update.mockClear()
|
||||
})
|
||||
|
||||
it('converts client positions into canvas coordinates', () => {
|
||||
const { clientPosToCanvasPos } = useCanvasPositionConversion(
|
||||
mocks.canvas.canvas,
|
||||
mocks.canvas
|
||||
)
|
||||
|
||||
expect(clientPosToCanvasPos([34, 66])).toEqual([5, 10])
|
||||
})
|
||||
|
||||
it('converts canvas positions into client coordinates', () => {
|
||||
const { canvasPosToClientPos } = useCanvasPositionConversion(
|
||||
mocks.canvas.canvas,
|
||||
mocks.canvas
|
||||
)
|
||||
|
||||
expect(canvasPosToClientPos([5, 10])).toEqual([34, 66])
|
||||
})
|
||||
|
||||
it('returns the element bounds update callback', () => {
|
||||
const { update } = useCanvasPositionConversion(
|
||||
mocks.canvas.canvas,
|
||||
mocks.canvas
|
||||
)
|
||||
|
||||
update()
|
||||
|
||||
expect(mocks.update).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('reuses the shared converter instance', () => {
|
||||
const first = useSharedCanvasPositionConversion()
|
||||
const second = useSharedCanvasPositionConversion()
|
||||
|
||||
expect(second).toBe(first)
|
||||
expect(mocks.getCanvas).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -1,82 +0,0 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useMutationObserver, useResizeObserver } from '@vueuse/core'
|
||||
|
||||
import { useOverflowObserver } from './useOverflowObserver'
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
useMutationObserver: vi.fn(() => ({ stop: vi.fn() })),
|
||||
useResizeObserver: vi.fn(() => ({ stop: vi.fn() }))
|
||||
}))
|
||||
|
||||
const useMutationObserverMock = vi.mocked(useMutationObserver)
|
||||
const useResizeObserverMock = vi.mocked(useResizeObserver)
|
||||
|
||||
function setElementWidths(
|
||||
element: HTMLElement,
|
||||
widths: { scrollWidth: number; clientWidth: number }
|
||||
) {
|
||||
Object.defineProperty(element, 'scrollWidth', {
|
||||
value: widths.scrollWidth,
|
||||
configurable: true
|
||||
})
|
||||
Object.defineProperty(element, 'clientWidth', {
|
||||
value: widths.clientWidth,
|
||||
configurable: true
|
||||
})
|
||||
}
|
||||
|
||||
describe('useOverflowObserver', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
useMutationObserverMock.mockReturnValue(fromPartial({ stop: vi.fn() }))
|
||||
useResizeObserverMock.mockReturnValue(fromPartial({ stop: vi.fn() }))
|
||||
})
|
||||
|
||||
it('checks overflow immediately when debounce is disabled', () => {
|
||||
const element = document.createElement('div')
|
||||
const onCheck = vi.fn()
|
||||
setElementWidths(element, { scrollWidth: 120, clientWidth: 100 })
|
||||
|
||||
const observer = useOverflowObserver(element, {
|
||||
debounceTime: 0,
|
||||
onCheck
|
||||
})
|
||||
|
||||
observer.checkOverflow()
|
||||
|
||||
expect(observer.isOverflowing.value).toBe(true)
|
||||
expect(onCheck).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('can skip observers and still dispose', () => {
|
||||
const element = document.createElement('div')
|
||||
|
||||
const observer = useOverflowObserver(element, {
|
||||
useMutationObserver: false,
|
||||
useResizeObserver: false
|
||||
})
|
||||
|
||||
observer.dispose()
|
||||
|
||||
expect(observer.disposed.value).toBe(true)
|
||||
expect(useMutationObserverMock).not.toHaveBeenCalled()
|
||||
expect(useResizeObserverMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stops enabled observers on dispose', () => {
|
||||
const element = document.createElement('div')
|
||||
const stopMutation = vi.fn()
|
||||
const stopResize = vi.fn()
|
||||
useMutationObserverMock.mockReturnValue(fromPartial({ stop: stopMutation }))
|
||||
useResizeObserverMock.mockReturnValue(fromPartial({ stop: stopResize }))
|
||||
|
||||
const observer = useOverflowObserver(element)
|
||||
|
||||
observer.dispose()
|
||||
|
||||
expect(stopMutation).toHaveBeenCalledOnce()
|
||||
expect(stopResize).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
import { LGraphCanvas, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import type { MenuOption } from './useMoreOptionsMenu'
|
||||
import {
|
||||
@@ -360,203 +360,5 @@ describe('contextMenuConverter', () => {
|
||||
)
|
||||
expect(hasExtensionsCategory).toBe(true)
|
||||
})
|
||||
|
||||
it('skips items without content and duplicate equivalents', () => {
|
||||
const result = convertContextMenuToOptions(
|
||||
[
|
||||
{ content: '', callback: () => {} },
|
||||
{ content: 'Duplicate', callback: () => {} },
|
||||
{ content: 'Clone', callback: () => {} }
|
||||
],
|
||||
undefined,
|
||||
false
|
||||
)
|
||||
|
||||
expect(result.map((option) => option.label)).toEqual(['Duplicate'])
|
||||
})
|
||||
|
||||
it('wraps callbacks and reports callback errors', () => {
|
||||
const callback = vi.fn()
|
||||
const error = new Error('callback failed')
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const result = convertContextMenuToOptions(
|
||||
[
|
||||
{ content: 'Run', value: 'run-value', callback },
|
||||
{
|
||||
content: 'Broken',
|
||||
callback: () => {
|
||||
throw error
|
||||
}
|
||||
},
|
||||
{ content: 'Disabled', disabled: true, callback: () => {} }
|
||||
],
|
||||
undefined,
|
||||
false
|
||||
)
|
||||
|
||||
result[0].action?.()
|
||||
result[1].action?.()
|
||||
|
||||
expect(callback).toHaveBeenCalledWith(
|
||||
'run-value',
|
||||
{},
|
||||
undefined,
|
||||
undefined,
|
||||
expect.objectContaining({ content: 'Run' })
|
||||
)
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
'Error executing context menu callback:',
|
||||
error
|
||||
)
|
||||
expect(result[2].action).toBeUndefined()
|
||||
|
||||
errorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('converts static submenus and submenu callbacks', () => {
|
||||
const submenuCallback = vi.fn()
|
||||
const error = new Error('submenu failed')
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const result = convertContextMenuToOptions(
|
||||
[
|
||||
{
|
||||
content: 'Static Submenu',
|
||||
has_submenu: true,
|
||||
submenu: {
|
||||
options: [
|
||||
'<b>ignored string without callback</b>',
|
||||
null,
|
||||
{
|
||||
content: '<b>Choice</b>',
|
||||
value: 'choice',
|
||||
callback: submenuCallback
|
||||
},
|
||||
{
|
||||
content: '<i>Disabled</i>',
|
||||
disabled: true
|
||||
},
|
||||
{
|
||||
content: '<span>Broken</span>',
|
||||
callback: () => {
|
||||
throw error
|
||||
}
|
||||
},
|
||||
{ content: '' }
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
undefined,
|
||||
false
|
||||
)
|
||||
|
||||
const submenu = result[0].submenu ?? []
|
||||
expect(result[0].hasSubmenu).toBe(true)
|
||||
expect(submenu.map((option) => option.label)).toEqual([
|
||||
'<b>ignored string without callback</b>',
|
||||
'Choice',
|
||||
'Disabled',
|
||||
'Broken'
|
||||
])
|
||||
expect(submenu[2].disabled).toBe(true)
|
||||
|
||||
submenu[1].action?.()
|
||||
submenu[3].action?.()
|
||||
|
||||
expect(submenuCallback).toHaveBeenCalledWith(
|
||||
'choice',
|
||||
{},
|
||||
undefined,
|
||||
undefined,
|
||||
expect.objectContaining({ content: '<b>Choice</b>' })
|
||||
)
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
'Error executing submenu callback:',
|
||||
error
|
||||
)
|
||||
|
||||
errorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('captures dynamic submenus created by callbacks', () => {
|
||||
const stringCallback = vi.fn()
|
||||
const objectCallback = vi.fn()
|
||||
const result = convertContextMenuToOptions(
|
||||
[
|
||||
{
|
||||
content: 'Dynamic Submenu',
|
||||
has_submenu: true,
|
||||
callback: () => {
|
||||
new LiteGraph.ContextMenu(
|
||||
[
|
||||
'Auto',
|
||||
{
|
||||
content: '<b>Object choice</b>',
|
||||
value: 'object',
|
||||
callback: objectCallback
|
||||
}
|
||||
],
|
||||
{ callback: stringCallback, extra: { source: 'test' } }
|
||||
)
|
||||
}
|
||||
}
|
||||
],
|
||||
undefined,
|
||||
false
|
||||
)
|
||||
|
||||
const submenu = result[0].submenu ?? []
|
||||
expect(result[0].hasSubmenu).toBe(true)
|
||||
expect(submenu.map((option) => option.label)).toEqual([
|
||||
'Auto',
|
||||
'Object choice'
|
||||
])
|
||||
|
||||
submenu[0].action?.()
|
||||
submenu[1].action?.()
|
||||
|
||||
expect(stringCallback).toHaveBeenCalledWith(
|
||||
'Auto',
|
||||
expect.objectContaining({ extra: { source: 'test' } }),
|
||||
undefined,
|
||||
undefined,
|
||||
{ source: 'test' }
|
||||
)
|
||||
expect(objectCallback).toHaveBeenCalledWith(
|
||||
'object',
|
||||
{},
|
||||
undefined,
|
||||
undefined,
|
||||
expect.objectContaining({ content: '<b>Object choice</b>' })
|
||||
)
|
||||
})
|
||||
|
||||
it('warns when dynamic submenu callbacks fail to provide items', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const result = convertContextMenuToOptions(
|
||||
[
|
||||
{
|
||||
content: 'Empty Dynamic Submenu',
|
||||
has_submenu: true,
|
||||
callback: () => {}
|
||||
}
|
||||
],
|
||||
undefined,
|
||||
false
|
||||
)
|
||||
|
||||
expect(result[0].hasSubmenu).toBe(true)
|
||||
expect(result[0].submenu).toBeUndefined()
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'[ContextMenuConverter] No items captured for:',
|
||||
'Empty Dynamic Submenu'
|
||||
)
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'[ContextMenuConverter] Failed to capture submenu for:',
|
||||
'Empty Dynamic Submenu'
|
||||
)
|
||||
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -20,7 +20,6 @@ import * as missingModelScan from '@/platform/missingModel/missingModelScan'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { ChangeTracker } from '@/scripts/changeTracker'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { createNodeExecutionId } from '@/types/nodeIdentification'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
@@ -131,39 +130,6 @@ describe('Connection error clearing via onConnectionsChange', () => {
|
||||
expect(store.lastNodeErrors).not.toBeNull()
|
||||
})
|
||||
|
||||
it('does not clear errors when a connected input has no root graph', () => {
|
||||
const { graph, node } = createGraphWithInput()
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
const store = useExecutionErrorStore()
|
||||
seedRequiredInputMissingNodeError(
|
||||
store,
|
||||
createNodeExecutionId([node.id]),
|
||||
'clip'
|
||||
)
|
||||
|
||||
node.onConnectionsChange!(NodeSlotType.INPUT, 0, true, null, node.inputs[0])
|
||||
|
||||
expect(store.lastNodeErrors).not.toBeNull()
|
||||
})
|
||||
|
||||
it('does not clear errors when a connected input has no slot name', () => {
|
||||
const { graph, node } = createGraphWithInput()
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
const store = useExecutionErrorStore()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
seedRequiredInputMissingNodeError(
|
||||
store,
|
||||
createNodeExecutionId([node.id]),
|
||||
'clip'
|
||||
)
|
||||
|
||||
node.onConnectionsChange!(NodeSlotType.INPUT, 12, true, null, fromAny(null))
|
||||
|
||||
expect(store.lastNodeErrors).not.toBeNull()
|
||||
})
|
||||
|
||||
it('clears errors for pure input slots without widget property', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
@@ -286,36 +252,6 @@ describe('Widget change error clearing via onWidgetChanged', () => {
|
||||
expect(store.lastNodeErrors).not.toBeNull()
|
||||
})
|
||||
|
||||
it('does not clear errors when the host execution id is unavailable', () => {
|
||||
const graph = new LGraph()
|
||||
const otherGraph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
node.addWidget('number', 'steps', 20, () => undefined, {})
|
||||
graph.add(node)
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
const store = useExecutionErrorStore()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(otherGraph)
|
||||
store.lastNodeErrors = {
|
||||
[String(node.id)]: {
|
||||
errors: [
|
||||
{
|
||||
type: 'value_bigger_than_max',
|
||||
message: 'Too big',
|
||||
details: '',
|
||||
extra_info: { input_name: 'steps' }
|
||||
}
|
||||
],
|
||||
dependent_outputs: [],
|
||||
class_type: 'TestNode'
|
||||
}
|
||||
}
|
||||
|
||||
node.onWidgetChanged!.call(node, 'steps', 50, 20, node.widgets![0])
|
||||
|
||||
expect(store.lastNodeErrors).not.toBeNull()
|
||||
})
|
||||
|
||||
it('clears missing media when an upload emits onWidgetChanged', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('LoadImage')
|
||||
@@ -455,124 +391,6 @@ describe('installErrorClearingHooks lifecycle', () => {
|
||||
expect(node.onConnectionsChange).toBe(chainedAfterFirst)
|
||||
})
|
||||
|
||||
it('removes unhooked nodes without restoring callbacks', () => {
|
||||
const graph = new LGraph()
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
const node = new LGraphNode('late')
|
||||
expect(() => graph.onNodeRemoved!(node)).not.toThrow()
|
||||
expect(node.onConnectionsChange).toBeUndefined()
|
||||
expect(node.onWidgetChanged).toBeUndefined()
|
||||
})
|
||||
|
||||
it('restores recursively installed callbacks on subgraph cleanup', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const innerNode = new LGraphNode('inner')
|
||||
const originalOnConnectionsChange = vi.fn()
|
||||
const originalOnWidgetChanged = vi.fn()
|
||||
innerNode.onConnectionsChange = originalOnConnectionsChange
|
||||
innerNode.onWidgetChanged = originalOnWidgetChanged
|
||||
subgraph.add(innerNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const graph = subgraph.rootGraph
|
||||
graph.add(subgraphNode)
|
||||
|
||||
const cleanup = installErrorClearingHooks(graph)
|
||||
|
||||
expect(innerNode.onConnectionsChange).not.toBe(originalOnConnectionsChange)
|
||||
expect(innerNode.onWidgetChanged).not.toBe(originalOnWidgetChanged)
|
||||
|
||||
cleanup()
|
||||
|
||||
expect(innerNode.onConnectionsChange).toBe(originalOnConnectionsChange)
|
||||
expect(innerNode.onWidgetChanged).toBe(originalOnWidgetChanged)
|
||||
})
|
||||
|
||||
it('restores undefined graph hooks when cleanup is called', () => {
|
||||
const graph = new LGraph()
|
||||
|
||||
const cleanup = installErrorClearingHooks(graph)
|
||||
cleanup()
|
||||
|
||||
expect(graph.onNodeAdded).toBeUndefined()
|
||||
expect(graph.onNodeRemoved).toBeUndefined()
|
||||
expect(graph.onTrigger).toBeUndefined()
|
||||
})
|
||||
|
||||
it('calls original graph hooks for added, removed, and trigger events', () => {
|
||||
const graph = new LGraph()
|
||||
const onNodeAdded = vi.fn()
|
||||
const onNodeRemoved = vi.fn()
|
||||
const onTrigger = vi.fn()
|
||||
graph.onNodeAdded = onNodeAdded
|
||||
graph.onNodeRemoved = onNodeRemoved
|
||||
graph.onTrigger = onTrigger
|
||||
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
const node = new LGraphNode('test')
|
||||
graph.onNodeAdded!(node)
|
||||
graph.onNodeRemoved!(node)
|
||||
graph.onTrigger!({
|
||||
type: 'node:property:changed',
|
||||
nodeId: node.id,
|
||||
property: 'title',
|
||||
oldValue: 'old',
|
||||
newValue: 'new'
|
||||
})
|
||||
|
||||
expect(onNodeAdded).toHaveBeenCalledWith(node)
|
||||
expect(onNodeRemoved).toHaveBeenCalledWith(node)
|
||||
expect(onTrigger).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ property: 'title' })
|
||||
)
|
||||
})
|
||||
|
||||
it('skips scanning added nodes while graph loading is in progress', async () => {
|
||||
const graph = new LGraph()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
const modelScan = vi.spyOn(missingModelScan, 'scanNodeModelCandidates')
|
||||
vi.spyOn(ChangeTracker, 'isLoadingGraph', 'get').mockReturnValue(true)
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
const node = new LGraphNode('CheckpointLoaderSimple')
|
||||
graph.add(node)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(modelScan).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips scanning added nodes when root graph is unavailable', async () => {
|
||||
const graph = new LGraph()
|
||||
const modelScan = vi.spyOn(missingModelScan, 'scanNodeModelCandidates')
|
||||
const mediaScan = vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates')
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
graph.add(new LGraphNode('CheckpointLoaderSimple'))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(modelScan).not.toHaveBeenCalled()
|
||||
expect(mediaScan).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips scanning added inactive nodes', async () => {
|
||||
const graph = new LGraph()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
const modelScan = vi.spyOn(missingModelScan, 'scanNodeModelCandidates')
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
const node = new LGraphNode('CheckpointLoaderSimple')
|
||||
node.mode = LGraphEventMode.BYPASS
|
||||
graph.add(node)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(modelScan).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('scans added-node missing models after widget values are restored', async () => {
|
||||
const graph = new LGraph()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
@@ -916,84 +734,6 @@ describe('realtime scan verifies pending cloud candidates', () => {
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
expect(useMissingModelStore().missingModelCandidates).toBeNull()
|
||||
})
|
||||
|
||||
it('logs pending model verification failures without surfacing candidates', async () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('CheckpointLoaderSimple')
|
||||
graph.add(node)
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([
|
||||
{
|
||||
nodeId: String(node.id),
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
isAssetSupported: true,
|
||||
name: 'broken.safetensors',
|
||||
isMissing: undefined
|
||||
}
|
||||
])
|
||||
vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates').mockReturnValue([])
|
||||
const verifySpy = vi
|
||||
.spyOn(missingModelScan, 'verifyAssetSupportedCandidates')
|
||||
.mockRejectedValue(new Error('nope'))
|
||||
const warnSpy = vi
|
||||
.spyOn(console, 'warn')
|
||||
.mockImplementation(() => undefined)
|
||||
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
node.mode = LGraphEventMode.ALWAYS
|
||||
graph.onTrigger?.({
|
||||
type: 'node:property:changed',
|
||||
nodeId: node.id,
|
||||
property: 'mode',
|
||||
oldValue: LGraphEventMode.BYPASS,
|
||||
newValue: LGraphEventMode.ALWAYS
|
||||
})
|
||||
|
||||
await vi.waitFor(() => expect(verifySpy).toHaveBeenCalledOnce())
|
||||
await vi.waitFor(() => expect(warnSpy).toHaveBeenCalledOnce())
|
||||
expect(useMissingModelStore().missingModelCandidates).toBeNull()
|
||||
})
|
||||
|
||||
it('logs pending media verification failures without surfacing candidates', async () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('LoadImage')
|
||||
graph.add(node)
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([])
|
||||
vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates').mockReturnValue([
|
||||
{
|
||||
nodeId: String(node.id),
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
name: 'broken.png',
|
||||
isMissing: undefined
|
||||
}
|
||||
])
|
||||
const verifySpy = vi
|
||||
.spyOn(missingMediaScan, 'verifyMediaCandidates')
|
||||
.mockRejectedValue(new Error('nope'))
|
||||
const warnSpy = vi
|
||||
.spyOn(console, 'warn')
|
||||
.mockImplementation(() => undefined)
|
||||
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
node.mode = LGraphEventMode.ALWAYS
|
||||
graph.onTrigger?.({
|
||||
type: 'node:property:changed',
|
||||
nodeId: node.id,
|
||||
property: 'mode',
|
||||
oldValue: LGraphEventMode.BYPASS,
|
||||
newValue: LGraphEventMode.ALWAYS
|
||||
})
|
||||
|
||||
await vi.waitFor(() => expect(verifySpy).toHaveBeenCalledOnce())
|
||||
await vi.waitFor(() => expect(warnSpy).toHaveBeenCalledOnce())
|
||||
expect(useMissingMediaStore().missingMediaCandidates).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('realtime verification staleness guards', () => {
|
||||
@@ -1153,54 +893,6 @@ describe('realtime verification staleness guards', () => {
|
||||
// result must not be added to the store.
|
||||
expect(useMissingModelStore().missingModelCandidates).toBeNull()
|
||||
})
|
||||
|
||||
it('skips adding verified media when rootGraph switched before verification resolved', async () => {
|
||||
const graphA = new LGraph()
|
||||
const nodeA = new LGraphNode('LoadImage')
|
||||
graphA.add(nodeA)
|
||||
const rootSpy = vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graphA)
|
||||
|
||||
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([])
|
||||
vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates').mockReturnValue([
|
||||
{
|
||||
nodeId: String(nodeA.id),
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
name: 'stale_from_A.png',
|
||||
isMissing: undefined
|
||||
}
|
||||
])
|
||||
let resolveVerify: (() => void) | undefined
|
||||
const verifyPromise = new Promise<void>((r) => (resolveVerify = r))
|
||||
const verifySpy = vi
|
||||
.spyOn(missingMediaScan, 'verifyMediaCandidates')
|
||||
.mockImplementation(async (candidates) => {
|
||||
await verifyPromise
|
||||
for (const c of candidates) c.isMissing = true
|
||||
})
|
||||
|
||||
installErrorClearingHooks(graphA)
|
||||
|
||||
nodeA.mode = LGraphEventMode.ALWAYS
|
||||
graphA.onTrigger?.({
|
||||
type: 'node:property:changed',
|
||||
nodeId: nodeA.id,
|
||||
property: 'mode',
|
||||
oldValue: LGraphEventMode.BYPASS,
|
||||
newValue: LGraphEventMode.ALWAYS
|
||||
})
|
||||
await vi.waitFor(() => expect(verifySpy).toHaveBeenCalledOnce())
|
||||
|
||||
const graphB = new LGraph()
|
||||
graphB.add(new LGraphNode('LoadImage'))
|
||||
rootSpy.mockReturnValue(graphB)
|
||||
|
||||
resolveVerify!()
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
|
||||
expect(useMissingMediaStore().missingMediaCandidates).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('scan skips interior of bypassed subgraph containers', () => {
|
||||
@@ -1312,167 +1004,6 @@ describe('scan skips interior of bypassed subgraph containers', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('skips inactive descendants during subgraph replay scans', async () => {
|
||||
const rootGraph = new LGraph()
|
||||
const subgraph = createTestSubgraph({ rootGraph })
|
||||
const activeNode = new LGraphNode('UNETLoader')
|
||||
const bypassedNode = new LGraphNode('CheckpointLoaderSimple')
|
||||
bypassedNode.mode = LGraphEventMode.BYPASS
|
||||
subgraph.add(activeNode)
|
||||
subgraph.add(bypassedNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, {
|
||||
parentGraph: rootGraph,
|
||||
id: 205
|
||||
})
|
||||
rootGraph.add(subgraphNode)
|
||||
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(rootGraph)
|
||||
const modelScanSpy = vi
|
||||
.spyOn(missingModelScan, 'scanNodeModelCandidates')
|
||||
.mockReturnValue([])
|
||||
vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates').mockReturnValue([])
|
||||
|
||||
installErrorClearingHooks(rootGraph)
|
||||
|
||||
rootGraph.onNodeAdded?.(subgraphNode)
|
||||
await Promise.resolve()
|
||||
|
||||
expect(modelScanSpy).toHaveBeenCalledWith(
|
||||
rootGraph,
|
||||
activeNode,
|
||||
expect.any(Function),
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(modelScanSpy).not.toHaveBeenCalledWith(
|
||||
rootGraph,
|
||||
bypassedNode,
|
||||
expect.any(Function),
|
||||
expect.any(Function)
|
||||
)
|
||||
})
|
||||
|
||||
it('surfaces missing node errors from the Unknown fallback type', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
node.type = fromAny<LGraphNode['type'], unknown>(undefined)
|
||||
graph.add(node)
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([])
|
||||
vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates').mockReturnValue([])
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
node.mode = LGraphEventMode.ALWAYS
|
||||
graph.onTrigger?.({
|
||||
type: 'node:property:changed',
|
||||
nodeId: node.id,
|
||||
property: 'mode',
|
||||
oldValue: LGraphEventMode.BYPASS,
|
||||
newValue: LGraphEventMode.ALWAYS
|
||||
})
|
||||
|
||||
expect(useMissingNodesErrorStore().missingNodesError?.nodeTypes).toEqual([
|
||||
expect.objectContaining({ type: 'Unknown', nodeId: String(node.id) })
|
||||
])
|
||||
})
|
||||
|
||||
it('does not show the overlay when un-bypass finds no missing errors', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const node = createTestSubgraphNode(subgraph)
|
||||
const graph = subgraph.rootGraph
|
||||
graph.add(node)
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([])
|
||||
vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates').mockReturnValue([])
|
||||
const showOverlay = vi.spyOn(useExecutionErrorStore(), 'showErrorOverlay')
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
node.mode = LGraphEventMode.ALWAYS
|
||||
graph.onTrigger?.({
|
||||
type: 'node:property:changed',
|
||||
nodeId: node.id,
|
||||
property: 'mode',
|
||||
oldValue: LGraphEventMode.BYPASS,
|
||||
newValue: LGraphEventMode.ALWAYS
|
||||
})
|
||||
|
||||
expect(showOverlay).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores mode changes that do not change active state', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('CheckpointLoaderSimple')
|
||||
graph.add(node)
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
const modelScan = vi.spyOn(missingModelScan, 'scanNodeModelCandidates')
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
graph.onTrigger?.({
|
||||
type: 'node:property:changed',
|
||||
nodeId: node.id,
|
||||
property: 'mode',
|
||||
oldValue: LGraphEventMode.ALWAYS,
|
||||
newValue: LGraphEventMode.ON_EVENT
|
||||
})
|
||||
|
||||
expect(modelScan).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores mode changes for missing local nodes', () => {
|
||||
const graph = new LGraph()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
const modelScan = vi.spyOn(missingModelScan, 'scanNodeModelCandidates')
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
graph.onTrigger?.({
|
||||
type: 'node:property:changed',
|
||||
nodeId: 999,
|
||||
property: 'mode',
|
||||
oldValue: LGraphEventMode.BYPASS,
|
||||
newValue: LGraphEventMode.ALWAYS
|
||||
})
|
||||
|
||||
expect(modelScan).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores mode changes when root graph is unavailable', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('CheckpointLoaderSimple')
|
||||
graph.add(node)
|
||||
const modelScan = vi.spyOn(missingModelScan, 'scanNodeModelCandidates')
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
graph.onTrigger?.({
|
||||
type: 'node:property:changed',
|
||||
nodeId: node.id,
|
||||
property: 'mode',
|
||||
oldValue: LGraphEventMode.BYPASS,
|
||||
newValue: LGraphEventMode.ALWAYS
|
||||
})
|
||||
|
||||
expect(modelScan).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores mode changes when the local node has no root execution id', () => {
|
||||
const graph = new LGraph()
|
||||
const rootGraph = new LGraph()
|
||||
const node = new LGraphNode('CheckpointLoaderSimple')
|
||||
graph.add(node)
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(rootGraph)
|
||||
const modelScan = vi.spyOn(missingModelScan, 'scanNodeModelCandidates')
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
graph.onTrigger?.({
|
||||
type: 'node:property:changed',
|
||||
nodeId: node.id,
|
||||
property: 'mode',
|
||||
oldValue: LGraphEventMode.BYPASS,
|
||||
newValue: LGraphEventMode.ALWAYS
|
||||
})
|
||||
|
||||
expect(modelScan).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('removes host-keyed promoted missing models when a source ancestor is bypassed', () => {
|
||||
const { rootGraph, outerSubgraph, innerSubgraphNode } =
|
||||
createNestedSubgraphRuntime()
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mockSelectionState = vi.hoisted(() => ({
|
||||
refs: null as null | {
|
||||
hasMultipleSelection: { value: boolean }
|
||||
}
|
||||
}))
|
||||
|
||||
const mockSettingStore = vi.hoisted(() => ({
|
||||
get: vi.fn()
|
||||
}))
|
||||
|
||||
const mockTitleEditorStore = vi.hoisted(() => ({
|
||||
titleEditorTarget: null as null | object
|
||||
}))
|
||||
|
||||
const mockApp = vi.hoisted(() => ({
|
||||
canvas: {
|
||||
selectedItems: new Set<object>(),
|
||||
graph: {
|
||||
add: vi.fn()
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
const mockGroups = vi.hoisted(() => ({
|
||||
instances: [] as Array<{
|
||||
resizeTo: ReturnType<typeof vi.fn>
|
||||
}>
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/graph/useSelectionState', async () => {
|
||||
const { ref } = await import('vue')
|
||||
const hasMultipleSelection = ref(false)
|
||||
mockSelectionState.refs = {
|
||||
hasMultipleSelection
|
||||
}
|
||||
|
||||
return {
|
||||
useSelectionState: () => ({
|
||||
hasMultipleSelection
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => mockSettingStore
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useTitleEditorStore: () => mockTitleEditorStore
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: mockApp
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/litegraph/src/litegraph', () => ({
|
||||
LGraphGroup: class MockLGraphGroup {
|
||||
resizeTo = vi.fn()
|
||||
|
||||
constructor() {
|
||||
mockGroups.instances.push(this)
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
describe('useFrameNodes', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
if (mockSelectionState.refs) {
|
||||
mockSelectionState.refs.hasMultipleSelection.value = false
|
||||
}
|
||||
mockSettingStore.get.mockReturnValue(24)
|
||||
mockTitleEditorStore.titleEditorTarget = null
|
||||
mockApp.canvas.selectedItems = new Set()
|
||||
mockApp.canvas.graph = {
|
||||
add: vi.fn()
|
||||
}
|
||||
mockGroups.instances = []
|
||||
})
|
||||
|
||||
it('exposes whether selected nodes can be framed', async () => {
|
||||
const { useFrameNodes } = await import('./useFrameNodes')
|
||||
const { canFrame } = useFrameNodes()
|
||||
|
||||
expect(canFrame.value).toBe(false)
|
||||
|
||||
if (!mockSelectionState.refs) {
|
||||
throw new Error('selection refs were not initialized')
|
||||
}
|
||||
mockSelectionState.refs.hasMultipleSelection.value = true
|
||||
|
||||
expect(canFrame.value).toBe(true)
|
||||
})
|
||||
|
||||
it('does nothing when no items are selected', async () => {
|
||||
const { useFrameNodes } = await import('./useFrameNodes')
|
||||
const { frameNodes } = useFrameNodes()
|
||||
|
||||
frameNodes()
|
||||
|
||||
expect(mockGroups.instances).toHaveLength(0)
|
||||
expect(mockApp.canvas.graph.add).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('frames selected items and opens the title editor on the new group', async () => {
|
||||
const selectedNode = {}
|
||||
mockApp.canvas.selectedItems = new Set([selectedNode])
|
||||
|
||||
const { useFrameNodes } = await import('./useFrameNodes')
|
||||
const { frameNodes } = useFrameNodes()
|
||||
|
||||
frameNodes()
|
||||
|
||||
const group = mockGroups.instances[0]
|
||||
expect(group.resizeTo).toHaveBeenCalledWith(
|
||||
mockApp.canvas.selectedItems,
|
||||
24
|
||||
)
|
||||
expect(mockApp.canvas.graph.add).toHaveBeenCalledWith(group)
|
||||
expect(mockTitleEditorStore.titleEditorTarget).toBe(group)
|
||||
})
|
||||
})
|
||||
@@ -1,14 +1,9 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed, nextTick, watch } from 'vue'
|
||||
|
||||
import {
|
||||
extractVueNodeData,
|
||||
getControlWidget,
|
||||
useGraphNodeManager
|
||||
} from '@/composables/graph/useGraphNodeManager'
|
||||
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
|
||||
import { BaseWidget, LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { widgetId } from '@/types/widgetId'
|
||||
import {
|
||||
@@ -19,10 +14,8 @@ import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { IS_CONTROL_WIDGET } from '@/scripts/widgets'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
describe('Node Reactivity', () => {
|
||||
beforeEach(() => {
|
||||
@@ -270,26 +263,6 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
|
||||
|
||||
expect(widgetData.slotMetadata).toBeUndefined()
|
||||
})
|
||||
|
||||
it('maps widget slot metadata even when the input slot name is empty', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
node.addWidget('string', 'prompt', 'hello', () => undefined, {})
|
||||
const input = node.addInput('', 'STRING')
|
||||
input.widget = { name: 'prompt' }
|
||||
graph.add(node)
|
||||
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const widgetData = vueNodeData
|
||||
.get(node.id)
|
||||
?.widgets?.find((w) => w.name === 'prompt')
|
||||
|
||||
expect(widgetData?.slotMetadata).toMatchObject({
|
||||
index: 0,
|
||||
linked: false,
|
||||
type: 'STRING'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Subgraph output slot label reactivity', () => {
|
||||
@@ -783,535 +756,3 @@ describe('Pre-remove vueNodeData drain', () => {
|
||||
).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Graph node manager property triggers', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
it('updates Vue node data for LiteGraph property change events', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
graph.add(node)
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
|
||||
graph.trigger('node:property:changed', {
|
||||
nodeId: node.id,
|
||||
property: 'title',
|
||||
newValue: 'Renamed'
|
||||
})
|
||||
graph.trigger('node:property:changed', {
|
||||
nodeId: node.id,
|
||||
property: 'has_errors',
|
||||
newValue: true
|
||||
})
|
||||
graph.trigger('node:property:changed', {
|
||||
nodeId: node.id,
|
||||
property: 'flags.collapsed',
|
||||
newValue: true
|
||||
})
|
||||
graph.trigger('node:property:changed', {
|
||||
nodeId: node.id,
|
||||
property: 'flags.ghost',
|
||||
newValue: true
|
||||
})
|
||||
graph.trigger('node:property:changed', {
|
||||
nodeId: node.id,
|
||||
property: 'flags.pinned',
|
||||
newValue: true
|
||||
})
|
||||
graph.trigger('node:property:changed', {
|
||||
nodeId: node.id,
|
||||
property: 'mode',
|
||||
newValue: 4
|
||||
})
|
||||
graph.trigger('node:property:changed', {
|
||||
nodeId: node.id,
|
||||
property: 'color',
|
||||
newValue: '#123456'
|
||||
})
|
||||
graph.trigger('node:property:changed', {
|
||||
nodeId: node.id,
|
||||
property: 'bgcolor',
|
||||
newValue: '#abcdef'
|
||||
})
|
||||
graph.trigger('node:property:changed', {
|
||||
nodeId: node.id,
|
||||
property: 'shape',
|
||||
newValue: 2
|
||||
})
|
||||
graph.trigger('node:property:changed', {
|
||||
nodeId: node.id,
|
||||
property: 'showAdvanced',
|
||||
newValue: true
|
||||
})
|
||||
graph.trigger('node:property:changed', {
|
||||
nodeId: node.id,
|
||||
property: 'badges',
|
||||
newValue: [{ text: 'hot' }]
|
||||
})
|
||||
|
||||
expect(vueNodeData.get(node.id)).toMatchObject({
|
||||
title: 'Renamed',
|
||||
hasErrors: true,
|
||||
flags: {
|
||||
collapsed: true,
|
||||
ghost: true,
|
||||
pinned: true
|
||||
},
|
||||
mode: 4,
|
||||
color: '#123456',
|
||||
bgcolor: '#abcdef',
|
||||
shape: 2,
|
||||
showAdvanced: true,
|
||||
badges: [{ text: 'hot' }]
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes invalid property payloads to safe Vue node data', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
graph.add(node)
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
|
||||
graph.trigger('node:property:changed', {
|
||||
nodeId: node.id,
|
||||
property: 'mode',
|
||||
newValue: 'invalid'
|
||||
})
|
||||
graph.trigger('node:property:changed', {
|
||||
nodeId: node.id,
|
||||
property: 'color',
|
||||
newValue: false
|
||||
})
|
||||
graph.trigger('node:property:changed', {
|
||||
nodeId: node.id,
|
||||
property: 'bgcolor',
|
||||
newValue: 123
|
||||
})
|
||||
graph.trigger('node:property:changed', {
|
||||
nodeId: node.id,
|
||||
property: 'shape',
|
||||
newValue: 'round'
|
||||
})
|
||||
|
||||
expect(vueNodeData.get(node.id)).toMatchObject({
|
||||
mode: 0,
|
||||
color: undefined,
|
||||
bgcolor: undefined,
|
||||
shape: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores property events for nodes the manager does not track', () => {
|
||||
const graph = new LGraph()
|
||||
useGraphNodeManager(graph)
|
||||
|
||||
expect(() =>
|
||||
graph.trigger('node:property:changed', {
|
||||
nodeId: 'missing',
|
||||
property: 'title',
|
||||
newValue: 'ignored'
|
||||
})
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it('ignores non-input slot link events and refreshes slot error metadata', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
node.addWidget('string', 'prompt', 'hello', () => undefined)
|
||||
const input = node.addInput('prompt', 'STRING')
|
||||
input.widget = { name: 'prompt' }
|
||||
graph.add(node)
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const widgetData = vueNodeData
|
||||
.get(node.id)
|
||||
?.widgets?.find((w) => w.name === 'prompt')
|
||||
|
||||
graph.trigger('node:slot-links:changed', {
|
||||
nodeId: node.id,
|
||||
slotType: NodeSlotType.OUTPUT
|
||||
})
|
||||
expect(widgetData?.slotMetadata?.linked).toBe(false)
|
||||
|
||||
input.link = fromAny(123)
|
||||
graph.trigger('node:slot-errors:changed', {
|
||||
nodeId: node.id
|
||||
})
|
||||
|
||||
expect(widgetData?.slotMetadata?.linked).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractVueNodeData widget mapping', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
it('normalizes widget callback values and redraws sibling widgets', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
const callback = vi.fn()
|
||||
const siblingTriggerDraw = vi.fn()
|
||||
node.addWidget('string', 'prompt', 'hello', callback)
|
||||
node.addCustomWidget(
|
||||
fromAny<IBaseWidget, unknown>({
|
||||
name: 'sibling',
|
||||
type: 'text',
|
||||
value: '',
|
||||
options: {},
|
||||
triggerDraw: siblingTriggerDraw
|
||||
})
|
||||
)
|
||||
graph.add(node)
|
||||
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const widgetData = vueNodeData
|
||||
.get(node.id)
|
||||
?.widgets?.find((widget) => widget.name === 'prompt')
|
||||
if (!widgetData?.callback) throw new Error('Missing widget callback')
|
||||
|
||||
widgetData.callback(null)
|
||||
expect(node.widgets![0].value).toBeUndefined()
|
||||
|
||||
widgetData.callback('text')
|
||||
expect(node.widgets![0].value).toBe('text')
|
||||
|
||||
widgetData.callback(3)
|
||||
expect(node.widgets![0].value).toBe(3)
|
||||
|
||||
widgetData.callback(true)
|
||||
expect(node.widgets![0].value).toBe(true)
|
||||
|
||||
const objectValue = { nested: true }
|
||||
widgetData.callback(objectValue)
|
||||
expect(node.widgets![0].value).toStrictEqual(objectValue)
|
||||
|
||||
const fileValues = [new File(['x'], 'x.txt')]
|
||||
widgetData.callback(fileValues)
|
||||
expect(node.widgets![0].value).toHaveLength(1)
|
||||
expect((node.widgets![0].value as File[])[0]).toBeInstanceOf(File)
|
||||
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
widgetData.callback(Symbol('invalid'))
|
||||
|
||||
expect(node.widgets![0].value).toBeUndefined()
|
||||
expect(callback).toHaveBeenLastCalledWith(undefined, app.canvas, node)
|
||||
expect(siblingTriggerDraw).toHaveBeenCalled()
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'Invalid widget value type: symbol',
|
||||
expect.any(Symbol)
|
||||
)
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('extracts display, DOM, layout, tooltip, and duplicate widget metadata', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
node.addCustomWidget({
|
||||
name: 'plain',
|
||||
type: 'text',
|
||||
value: 'a'
|
||||
} as IBaseWidget)
|
||||
node.addCustomWidget(
|
||||
fromAny<IBaseWidget, unknown>({
|
||||
name: 'plain',
|
||||
type: 'text',
|
||||
value: 'b',
|
||||
advanced: true,
|
||||
element: document.createElement('input'),
|
||||
computeLayoutSize: () => ({ minWidth: 1, minHeight: 1 }),
|
||||
options: {
|
||||
canvasOnly: true,
|
||||
hidden: true,
|
||||
read_only: true
|
||||
},
|
||||
tooltip: 'Details'
|
||||
})
|
||||
)
|
||||
graph.add(node)
|
||||
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const widgets = vueNodeData.get(node.id)?.widgets
|
||||
|
||||
expect(widgets?.[0]?.options).toBeUndefined()
|
||||
expect(widgets?.[1]).toMatchObject({
|
||||
name: 'plain',
|
||||
type: 'text',
|
||||
hasLayoutSize: true,
|
||||
isDOMWidget: true,
|
||||
tooltip: 'Details',
|
||||
options: {
|
||||
canvasOnly: true,
|
||||
advanced: true,
|
||||
hidden: true,
|
||||
read_only: true
|
||||
}
|
||||
})
|
||||
expect(widgets?.[0]?.widgetId).toBeDefined()
|
||||
expect(widgets?.[1]?.widgetId).toBeDefined()
|
||||
})
|
||||
|
||||
it('falls back to safe widget data when a widget mapper throws', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const node = new LGraphNode('test')
|
||||
const badWidget = fromAny<IBaseWidget, unknown>({
|
||||
name: 'broken',
|
||||
type: 'custom',
|
||||
value: 'x',
|
||||
get options() {
|
||||
throw new Error('bad options')
|
||||
}
|
||||
})
|
||||
node.widgets = [badWidget]
|
||||
|
||||
const data = extractVueNodeData(node)
|
||||
|
||||
expect(data.widgets?.[0]).toEqual({ name: 'broken', type: 'custom' })
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'[safeWidgetMapper] Failed to map widget:',
|
||||
'broken',
|
||||
expect.any(Error)
|
||||
)
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('falls back to unknown widget data when a broken widget has no name or type', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const node = new LGraphNode('test')
|
||||
const badWidget = fromAny<IBaseWidget, unknown>({
|
||||
value: 'x',
|
||||
get options() {
|
||||
throw new Error('bad options')
|
||||
}
|
||||
})
|
||||
node.widgets = [badWidget]
|
||||
|
||||
const data = extractVueNodeData(node)
|
||||
|
||||
expect(data.widgets?.[0]).toEqual({ name: 'unknown', type: 'text' })
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('keeps custom widgets getter results in sync', () => {
|
||||
const node = new LGraphNode('test')
|
||||
let widgets = [
|
||||
{
|
||||
name: 'first',
|
||||
type: 'text',
|
||||
value: 'one',
|
||||
options: {}
|
||||
} as IBaseWidget
|
||||
]
|
||||
Object.defineProperty(node, 'widgets', {
|
||||
get() {
|
||||
return widgets
|
||||
},
|
||||
configurable: true
|
||||
})
|
||||
|
||||
const data = extractVueNodeData(node)
|
||||
expect(data.widgets?.map((widget) => widget.name)).toEqual(['first'])
|
||||
|
||||
widgets = [
|
||||
{
|
||||
name: 'second',
|
||||
type: 'text',
|
||||
value: 'two',
|
||||
options: {}
|
||||
} as IBaseWidget
|
||||
]
|
||||
|
||||
expect(node.widgets?.map((widget) => widget.name)).toEqual(['second'])
|
||||
expect(data.widgets?.map((widget) => widget.name)).toEqual(['second'])
|
||||
})
|
||||
|
||||
it('treats undefined custom widget getter results as an empty widget list', () => {
|
||||
const node = new LGraphNode('test')
|
||||
Object.defineProperty(node, 'widgets', {
|
||||
get() {
|
||||
return undefined
|
||||
},
|
||||
configurable: true
|
||||
})
|
||||
|
||||
const data = extractVueNodeData(node)
|
||||
|
||||
expect(data.widgets?.length).toBe(0)
|
||||
})
|
||||
|
||||
it('derives node type fallbacks and subgraph id from graph context', () => {
|
||||
const node = new LGraphNode('')
|
||||
node.type = ''
|
||||
Object.defineProperty(node, 'constructor', {
|
||||
value: { title: 'FallbackTitle', nodeData: { api_node: true } },
|
||||
configurable: true
|
||||
})
|
||||
node.graph = {
|
||||
id: 'subgraph-id',
|
||||
rootGraph: new LGraph()
|
||||
} as LGraph
|
||||
|
||||
const data = extractVueNodeData(node)
|
||||
|
||||
expect(data.type).toBe('FallbackTitle')
|
||||
expect(data.subgraphId).toBe('subgraph-id')
|
||||
expect(data.apiNode).toBe(true)
|
||||
})
|
||||
|
||||
it('preserves flags when extracting Vue node data', () => {
|
||||
const node = new LGraphNode('test')
|
||||
node.flags = { collapsed: true, pinned: true }
|
||||
|
||||
const data = extractVueNodeData(node)
|
||||
|
||||
expect(data.flags).toEqual({ collapsed: true, pinned: true })
|
||||
})
|
||||
|
||||
it('keeps existing promoted widget state when mapping host widgets', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'ckpt_input', type: '*' }]
|
||||
})
|
||||
const interiorNode = new LGraphNode('CheckpointLoaderSimple')
|
||||
const interiorInput = interiorNode.addInput('ckpt_input', '*')
|
||||
interiorNode.addWidget(
|
||||
'combo',
|
||||
'ckpt_name',
|
||||
'source.safetensors',
|
||||
() => undefined,
|
||||
{
|
||||
values: ['source.safetensors']
|
||||
}
|
||||
)
|
||||
interiorInput.widget = { name: 'ckpt_name' }
|
||||
subgraph.add(interiorNode)
|
||||
subgraph.inputNode.slots[0].connect(interiorInput, interiorNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
|
||||
subgraphNode._internalConfigureAfterSlots()
|
||||
const graph = subgraphNode.graph as LGraph
|
||||
graph.add(subgraphNode)
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
|
||||
const id = subgraphNode.inputs[0].widgetId
|
||||
if (!id) throw new Error('Expected promoted input to have widgetId')
|
||||
const widgetStore = useWidgetValueStore()
|
||||
if (widgetStore.getWidget(id)) {
|
||||
widgetStore.setValue(id, 'existing.safetensors')
|
||||
} else {
|
||||
widgetStore.registerWidget(id, {
|
||||
type: 'combo',
|
||||
value: 'existing.safetensors',
|
||||
options: {},
|
||||
label: 'Existing'
|
||||
})
|
||||
}
|
||||
|
||||
useGraphNodeManager(graph)
|
||||
|
||||
expect(widgetStore.getWidget(id)?.value).toBe('existing.safetensors')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Graph node manager lifecycle hooks', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
it('defers layout extraction until graph configuration completes', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
node.title = 'Before'
|
||||
const originalOnNodeAdded = vi.fn()
|
||||
const originalAfterConfigured = vi.fn()
|
||||
graph.onNodeAdded = originalOnNodeAdded
|
||||
node.onAfterGraphConfigured = originalAfterConfigured
|
||||
const originalWindowApp = window.app
|
||||
window.app = { configuringGraph: true } as Window['app']
|
||||
|
||||
try {
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
graph.add(node)
|
||||
|
||||
expect(originalOnNodeAdded).toHaveBeenCalledWith(node)
|
||||
expect(vueNodeData.get(node.id)?.title).toBe('Before')
|
||||
|
||||
node.title = 'After'
|
||||
node.onAfterGraphConfigured?.()
|
||||
|
||||
expect(originalAfterConfigured).toHaveBeenCalled()
|
||||
expect(vueNodeData.get(node.id)?.title).toBe('After')
|
||||
} finally {
|
||||
window.app = originalWindowApp
|
||||
}
|
||||
})
|
||||
|
||||
it('chains original remove and trigger handlers, then restores them on cleanup', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
const originalOnNodeAdded = vi.fn()
|
||||
const originalOnNodeRemoved = vi.fn()
|
||||
const originalOnTrigger = vi.fn()
|
||||
graph.onNodeAdded = originalOnNodeAdded
|
||||
graph.onNodeRemoved = originalOnNodeRemoved
|
||||
graph.onTrigger = originalOnTrigger
|
||||
|
||||
const manager = useGraphNodeManager(graph)
|
||||
graph.add(node)
|
||||
graph.trigger('node:property:changed', {
|
||||
nodeId: node.id,
|
||||
property: 'title',
|
||||
newValue: 'Renamed'
|
||||
})
|
||||
graph.remove(node)
|
||||
|
||||
expect(originalOnNodeAdded).toHaveBeenCalledWith(node)
|
||||
expect(originalOnTrigger).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'node:property:changed' })
|
||||
)
|
||||
expect(originalOnNodeRemoved).toHaveBeenCalledWith(node)
|
||||
expect(manager.vueNodeData.size).toBe(0)
|
||||
|
||||
manager.cleanup()
|
||||
|
||||
expect(graph.onNodeAdded).toBe(originalOnNodeAdded)
|
||||
expect(graph.onNodeRemoved).toBe(originalOnNodeRemoved)
|
||||
expect(graph.onTrigger).toBe(originalOnTrigger)
|
||||
})
|
||||
|
||||
it('cleans up to undefined when no original callbacks existed', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
graph.add(node)
|
||||
|
||||
const manager = useGraphNodeManager(graph)
|
||||
expect(manager.vueNodeData.has(node.id)).toBe(true)
|
||||
|
||||
manager.cleanup()
|
||||
|
||||
expect(graph.onNodeAdded).toBeUndefined()
|
||||
expect(graph.onNodeRemoved).toBeUndefined()
|
||||
expect(graph.onTrigger).toBeUndefined()
|
||||
expect(manager.vueNodeData.size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getControlWidget', () => {
|
||||
it('normalizes linked control widget values and updates the source widget', () => {
|
||||
const linkedControl = {
|
||||
[IS_CONTROL_WIDGET]: true,
|
||||
value: 'fixed'
|
||||
}
|
||||
const widget = {
|
||||
linkedWidgets: [linkedControl]
|
||||
} as unknown as IBaseWidget
|
||||
|
||||
const control = getControlWidget(widget)
|
||||
|
||||
expect(control?.value).toBe('fixed')
|
||||
|
||||
control?.update('unexpected')
|
||||
|
||||
expect(linkedControl.value).toBe('randomize')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,215 +0,0 @@
|
||||
import type * as VueI18n from 'vue-i18n'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphGroup } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useGroupMenuOptions } from '@/composables/graph/useGroupMenuOptions'
|
||||
|
||||
const { canvas, captureCanvasState, isLightTheme, refreshCanvas, settings } =
|
||||
vi.hoisted(() => ({
|
||||
canvas: { setDirty: vi.fn() },
|
||||
captureCanvasState: vi.fn(),
|
||||
isLightTheme: { value: false },
|
||||
refreshCanvas: vi.fn(),
|
||||
settings: { 'Comfy.GroupSelectedNodes.Padding': 10 } as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof VueI18n>()),
|
||||
useI18n: () => ({ t: (key: string) => key })
|
||||
}))
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({ get: (k: string) => settings[k] })
|
||||
}))
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
activeWorkflow: { changeTracker: { captureCanvasState } }
|
||||
})
|
||||
}))
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({ canvas })
|
||||
}))
|
||||
vi.mock('@/composables/graph/useCanvasRefresh', () => ({
|
||||
useCanvasRefresh: () => ({ refreshCanvas })
|
||||
}))
|
||||
vi.mock('@/composables/graph/useNodeCustomization', () => ({
|
||||
useNodeCustomization: () => ({
|
||||
shapeOptions: [{ value: 1, localizedName: 'Box' }],
|
||||
colorOptions: [
|
||||
{ value: { dark: '#111', light: '#eee' }, localizedName: 'Red' }
|
||||
],
|
||||
isLightTheme
|
||||
})
|
||||
}))
|
||||
|
||||
function group(over: Record<string, unknown> = {}): LGraphGroup {
|
||||
return {
|
||||
recomputeInsideNodes: vi.fn(),
|
||||
resizeTo: vi.fn(),
|
||||
children: [],
|
||||
graph: { change: vi.fn() },
|
||||
nodes: [],
|
||||
...over
|
||||
} as unknown as LGraphGroup
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
canvas.setDirty.mockReset()
|
||||
captureCanvasState.mockReset()
|
||||
isLightTheme.value = false
|
||||
refreshCanvas.mockReset()
|
||||
})
|
||||
|
||||
describe('useGroupMenuOptions', () => {
|
||||
it('fits a group to its nodes, resizing with the configured padding', () => {
|
||||
const g = group()
|
||||
useGroupMenuOptions().getFitGroupToNodesOption(g).action?.()
|
||||
|
||||
expect(g.recomputeInsideNodes).toHaveBeenCalled()
|
||||
expect(g.resizeTo).toHaveBeenCalledWith(g.children, 10)
|
||||
expect(canvas.setDirty).toHaveBeenCalledWith(true, true)
|
||||
expect(captureCanvasState).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('aborts the fit action when recompute throws', () => {
|
||||
const g = group({
|
||||
recomputeInsideNodes: vi.fn(() => {
|
||||
throw new Error('boom')
|
||||
})
|
||||
})
|
||||
useGroupMenuOptions().getFitGroupToNodesOption(g).action?.()
|
||||
|
||||
expect(g.resizeTo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('applies a shape to all group nodes via the shape submenu', () => {
|
||||
const node = { shape: 0, mode: LGraphEventMode.ALWAYS }
|
||||
const bump = vi.fn()
|
||||
const option = useGroupMenuOptions().getGroupShapeOptions(
|
||||
group({ nodes: [node] }),
|
||||
bump
|
||||
)
|
||||
option.submenu?.[0].action?.()
|
||||
|
||||
expect(node.shape).toBe(1)
|
||||
expect(refreshCanvas).toHaveBeenCalled()
|
||||
expect(bump).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handles shape actions when a group has no nodes array', () => {
|
||||
const bump = vi.fn()
|
||||
useGroupMenuOptions()
|
||||
.getGroupShapeOptions(group({ nodes: undefined }), bump)
|
||||
.submenu?.[0].action?.()
|
||||
|
||||
expect(refreshCanvas).toHaveBeenCalled()
|
||||
expect(bump).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('applies a color to the group via the color submenu (dark theme)', () => {
|
||||
const g = group()
|
||||
const bump = vi.fn()
|
||||
useGroupMenuOptions().getGroupColorOptions(g, bump).submenu?.[0].action?.()
|
||||
|
||||
expect((g as unknown as { color: string }).color).toBe('#111')
|
||||
expect(bump).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('applies a light-theme color to the group via the color submenu', () => {
|
||||
const g = group()
|
||||
const bump = vi.fn()
|
||||
isLightTheme.value = true
|
||||
useGroupMenuOptions().getGroupColorOptions(g, bump).submenu?.[0].action?.()
|
||||
|
||||
expect((g as unknown as { color: string }).color).toBe('#eee')
|
||||
expect(bump).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns no mode options for an empty group', () => {
|
||||
expect(useGroupMenuOptions().getGroupModeOptions(group(), vi.fn())).toEqual(
|
||||
[]
|
||||
)
|
||||
})
|
||||
|
||||
it('returns no mode options when a group has no nodes array', () => {
|
||||
expect(
|
||||
useGroupMenuOptions().getGroupModeOptions(
|
||||
group({ nodes: undefined }),
|
||||
vi.fn()
|
||||
)
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('returns no mode options when recomputing group nodes fails', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const options = useGroupMenuOptions().getGroupModeOptions(
|
||||
group({
|
||||
recomputeInsideNodes: vi.fn(() => {
|
||||
throw new Error('boom')
|
||||
})
|
||||
}),
|
||||
vi.fn()
|
||||
)
|
||||
|
||||
expect(options).toEqual([])
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'Failed to recompute nodes in group for mode options:',
|
||||
expect.any(Error)
|
||||
)
|
||||
})
|
||||
|
||||
it('builds mode options for uniform nodes and applies the new mode', () => {
|
||||
const node = { shape: 0, mode: LGraphEventMode.ALWAYS }
|
||||
const bump = vi.fn()
|
||||
const options = useGroupMenuOptions().getGroupModeOptions(
|
||||
group({ nodes: [node] }),
|
||||
bump
|
||||
)
|
||||
|
||||
expect(options.length).toBeGreaterThan(0)
|
||||
options[0].action?.()
|
||||
expect(node.mode).not.toBe(LGraphEventMode.ALWAYS)
|
||||
expect(canvas.setDirty).toHaveBeenCalledWith(true, true)
|
||||
expect(bump).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('offers two alternate modes when all nodes are NEVER', () => {
|
||||
const options = useGroupMenuOptions().getGroupModeOptions(
|
||||
group({ nodes: [{ mode: LGraphEventMode.NEVER }] }),
|
||||
vi.fn()
|
||||
)
|
||||
expect(options).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('offers two alternate modes when all nodes are BYPASS', () => {
|
||||
const options = useGroupMenuOptions().getGroupModeOptions(
|
||||
group({ nodes: [{ mode: LGraphEventMode.BYPASS }] }),
|
||||
vi.fn()
|
||||
)
|
||||
expect(options).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('offers all three modes when nodes have mixed modes', () => {
|
||||
const options = useGroupMenuOptions().getGroupModeOptions(
|
||||
group({
|
||||
nodes: [
|
||||
{ mode: LGraphEventMode.ALWAYS },
|
||||
{ mode: LGraphEventMode.NEVER }
|
||||
]
|
||||
}),
|
||||
vi.fn()
|
||||
)
|
||||
expect(options).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('offers all three modes when the uniform mode is unknown', () => {
|
||||
const options = useGroupMenuOptions().getGroupModeOptions(
|
||||
group({ nodes: [{ mode: 999 }] }),
|
||||
vi.fn()
|
||||
)
|
||||
expect(options).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,6 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { downloadFile, openFileInNewTab } from '@/base/common/downloadUtil'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
|
||||
import { useImageMenuOptions } from './useImageMenuOptions'
|
||||
@@ -20,11 +19,6 @@ vi.mock('@/stores/commandStore', () => ({
|
||||
useCommandStore: () => ({ execute: vi.fn() })
|
||||
}))
|
||||
|
||||
vi.mock('@/base/common/downloadUtil', () => ({
|
||||
downloadFile: vi.fn(),
|
||||
openFileInNewTab: vi.fn()
|
||||
}))
|
||||
|
||||
function mockClipboard(clipboard: Partial<Clipboard> | undefined) {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: clipboard,
|
||||
@@ -33,15 +27,6 @@ function mockClipboard(clipboard: Partial<Clipboard> | undefined) {
|
||||
})
|
||||
}
|
||||
|
||||
function stubClipboardItem() {
|
||||
vi.stubGlobal(
|
||||
'ClipboardItem',
|
||||
class ClipboardItemStub {
|
||||
constructor(public readonly items: Record<string, Blob>) {}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function createImageNode(
|
||||
overrides: Partial<LGraphNode> | Record<string, unknown> = {}
|
||||
): LGraphNode {
|
||||
@@ -60,13 +45,8 @@ function createImageNode(
|
||||
}
|
||||
|
||||
describe('useImageMenuOptions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('getImageMenuOptions', () => {
|
||||
@@ -95,12 +75,6 @@ describe('useImageMenuOptions', () => {
|
||||
expect(getImageMenuOptions(node)).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array when node image capabilities are absent', () => {
|
||||
const { getImageMenuOptions } = useImageMenuOptions()
|
||||
|
||||
expect(getImageMenuOptions(fromPartial<LGraphNode>({}))).toEqual([])
|
||||
})
|
||||
|
||||
it('returns only Paste Image when node has no images but supports paste', () => {
|
||||
const node = createMockLGraphNode({
|
||||
imgs: [],
|
||||
@@ -208,225 +182,4 @@ describe('useImageMenuOptions', () => {
|
||||
expect(node.pasteFiles).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('image actions', () => {
|
||||
it('opens the selected image without preview query params', () => {
|
||||
const node = createImageNode()
|
||||
node.imgs![0].src = 'http://localhost/test.png?preview=1&foo=bar'
|
||||
|
||||
const { getImageMenuOptions } = useImageMenuOptions()
|
||||
const openOption = getImageMenuOptions(node).find(
|
||||
(o) => o.label === 'Open Image'
|
||||
)
|
||||
openOption?.action?.()
|
||||
|
||||
expect(openFileInNewTab).toHaveBeenCalledWith(
|
||||
'http://localhost/test.png?foo=bar'
|
||||
)
|
||||
})
|
||||
|
||||
it('saves the selected image without preview query params', () => {
|
||||
const node = createImageNode()
|
||||
node.imgs![0].src = 'http://localhost/test.png?preview=1&foo=bar'
|
||||
|
||||
const { getImageMenuOptions } = useImageMenuOptions()
|
||||
const saveOption = getImageMenuOptions(node).find(
|
||||
(o) => o.label === 'Save Image'
|
||||
)
|
||||
saveOption?.action?.()
|
||||
|
||||
expect(downloadFile).toHaveBeenCalledWith(
|
||||
'http://localhost/test.png?foo=bar'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not open or save when the active image is missing', () => {
|
||||
const node = createImageNode({ imageIndex: 1 })
|
||||
|
||||
const { getImageMenuOptions } = useImageMenuOptions()
|
||||
const options = getImageMenuOptions(node)
|
||||
options.find((o) => o.label === 'Open Image')?.action?.()
|
||||
options.find((o) => o.label === 'Save Image')?.action?.()
|
||||
|
||||
expect(openFileInNewTab).not.toHaveBeenCalled()
|
||||
expect(downloadFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not run image actions when images are cleared after menu creation', async () => {
|
||||
const node = createImageNode()
|
||||
|
||||
const { getImageMenuOptions } = useImageMenuOptions()
|
||||
const options = getImageMenuOptions(node)
|
||||
node.imgs = []
|
||||
|
||||
options.find((o) => o.label === 'Open Image')?.action?.()
|
||||
await options.find((o) => o.label === 'Copy Image')?.action?.()
|
||||
options.find((o) => o.label === 'Save Image')?.action?.()
|
||||
|
||||
expect(openFileInNewTab).not.toHaveBeenCalled()
|
||||
expect(downloadFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not copy when the active image is missing', async () => {
|
||||
const node = createImageNode({ imageIndex: 1 })
|
||||
const write = vi.fn()
|
||||
mockClipboard(fromPartial<Clipboard>({ write }))
|
||||
|
||||
const { getImageMenuOptions } = useImageMenuOptions()
|
||||
await getImageMenuOptions(node)
|
||||
.find((o) => o.label === 'Copy Image')
|
||||
?.action?.()
|
||||
|
||||
expect(write).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('logs save failures for invalid image URLs', () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const node = createImageNode()
|
||||
Object.defineProperty(node.imgs![0], 'src', {
|
||||
value: 'http://[',
|
||||
configurable: true
|
||||
})
|
||||
|
||||
const { getImageMenuOptions } = useImageMenuOptions()
|
||||
getImageMenuOptions(node)
|
||||
.find((o) => o.label === 'Save Image')
|
||||
?.action?.()
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
'Failed to save image:',
|
||||
expect.any(TypeError)
|
||||
)
|
||||
expect(downloadFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('copies the selected image to clipboard', async () => {
|
||||
const node = createImageNode()
|
||||
const drawImage = vi.fn()
|
||||
const write = vi.fn().mockResolvedValue(undefined)
|
||||
stubClipboardItem()
|
||||
mockClipboard(fromPartial<Clipboard>({ write }))
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(
|
||||
(() =>
|
||||
fromPartial<CanvasRenderingContext2D>({
|
||||
drawImage
|
||||
})) as unknown as HTMLCanvasElement['getContext']
|
||||
)
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation(
|
||||
(callback: BlobCallback) => {
|
||||
callback(new Blob(['image'], { type: 'image/png' }))
|
||||
}
|
||||
)
|
||||
|
||||
const { getImageMenuOptions } = useImageMenuOptions()
|
||||
await getImageMenuOptions(node)
|
||||
.find((o) => o.label === 'Copy Image')
|
||||
?.action?.()
|
||||
|
||||
expect(drawImage).toHaveBeenCalledWith(node.imgs![0], 0, 0)
|
||||
expect(write).toHaveBeenCalledWith([
|
||||
expect.objectContaining({
|
||||
items: { 'image/png': expect.any(Blob) }
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('does not copy when canvas context is unavailable', async () => {
|
||||
const node = createImageNode()
|
||||
const write = vi.fn()
|
||||
mockClipboard(fromPartial<Clipboard>({ write }))
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(
|
||||
(() => null) as HTMLCanvasElement['getContext']
|
||||
)
|
||||
|
||||
const { getImageMenuOptions } = useImageMenuOptions()
|
||||
await getImageMenuOptions(node)
|
||||
.find((o) => o.label === 'Copy Image')
|
||||
?.action?.()
|
||||
|
||||
expect(write).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not copy when canvas blob creation fails', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const node = createImageNode()
|
||||
const write = vi.fn()
|
||||
mockClipboard(fromPartial<Clipboard>({ write }))
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(
|
||||
(() =>
|
||||
fromPartial<CanvasRenderingContext2D>({
|
||||
drawImage: vi.fn()
|
||||
})) as unknown as HTMLCanvasElement['getContext']
|
||||
)
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation(
|
||||
(callback: BlobCallback) => {
|
||||
callback(null)
|
||||
}
|
||||
)
|
||||
|
||||
const { getImageMenuOptions } = useImageMenuOptions()
|
||||
await getImageMenuOptions(node)
|
||||
.find((o) => o.label === 'Copy Image')
|
||||
?.action?.()
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith('Failed to create image blob')
|
||||
expect(write).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not copy when clipboard write is unavailable', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const node = createImageNode()
|
||||
mockClipboard(fromPartial<Clipboard>({ write: undefined }))
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(
|
||||
(() =>
|
||||
fromPartial<CanvasRenderingContext2D>({
|
||||
drawImage: vi.fn()
|
||||
})) as unknown as HTMLCanvasElement['getContext']
|
||||
)
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation(
|
||||
(callback: BlobCallback) => {
|
||||
callback(new Blob(['image'], { type: 'image/png' }))
|
||||
}
|
||||
)
|
||||
|
||||
const { getImageMenuOptions } = useImageMenuOptions()
|
||||
await getImageMenuOptions(node)
|
||||
.find((o) => o.label === 'Copy Image')
|
||||
?.action?.()
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith('Clipboard API not available')
|
||||
})
|
||||
|
||||
it('logs clipboard copy failures', async () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const node = createImageNode()
|
||||
stubClipboardItem()
|
||||
mockClipboard(
|
||||
fromPartial<Clipboard>({
|
||||
write: vi.fn().mockRejectedValue(new Error('blocked'))
|
||||
})
|
||||
)
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(
|
||||
(() =>
|
||||
fromPartial<CanvasRenderingContext2D>({
|
||||
drawImage: vi.fn()
|
||||
})) as unknown as HTMLCanvasElement['getContext']
|
||||
)
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation(
|
||||
(callback: BlobCallback) => {
|
||||
callback(new Blob(['image'], { type: 'image/png' }))
|
||||
}
|
||||
)
|
||||
|
||||
const { getImageMenuOptions } = useImageMenuOptions()
|
||||
await getImageMenuOptions(node)
|
||||
.find((o) => o.label === 'Copy Image')
|
||||
?.action?.()
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
'Failed to copy image to clipboard:',
|
||||
expect.any(Error)
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,292 +0,0 @@
|
||||
import { ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LGraphGroup } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
isNodeOptionsOpen,
|
||||
registerNodeOptionsInstance,
|
||||
showNodeOptions,
|
||||
toggleNodeOptions,
|
||||
useMoreOptionsMenu
|
||||
} from '@/composables/graph/useMoreOptionsMenu'
|
||||
|
||||
const {
|
||||
canvasState,
|
||||
extraWidgetOptions,
|
||||
imageOptions,
|
||||
nodeMenu,
|
||||
selectionMenu,
|
||||
selectionState
|
||||
} = vi.hoisted(() => ({
|
||||
canvasState: {
|
||||
canvas: undefined as
|
||||
| undefined
|
||||
| {
|
||||
getNodeMenuOptions: ReturnType<typeof vi.fn>
|
||||
}
|
||||
},
|
||||
extraWidgetOptions: {
|
||||
value: [] as Array<{ content: string; callback?: () => void }>
|
||||
},
|
||||
imageOptions: {
|
||||
value: [] as Array<{ label: string; hasSubmenu?: boolean; submenu?: [] }>
|
||||
},
|
||||
nodeMenu: {
|
||||
visualOptions: {
|
||||
value: [] as Array<{
|
||||
label: string
|
||||
hasSubmenu?: boolean
|
||||
submenu?: Array<{ label: string; action: () => void }>
|
||||
}>
|
||||
}
|
||||
},
|
||||
selectionMenu: {
|
||||
basicOptions: { value: [{ label: 'Copy' }] },
|
||||
multipleOptions: { value: [{ label: 'Align' }] },
|
||||
subgraphOptions: { value: [] as Array<{ label: string }> }
|
||||
},
|
||||
selectionState: {
|
||||
selectedItems: { value: [] as unknown[] },
|
||||
selectedNodes: { value: [] as unknown[] },
|
||||
canOpenNodeInfo: { value: false },
|
||||
openNodeInfo: vi.fn(() => true),
|
||||
hasSubgraphs: { value: false },
|
||||
hasImageNode: { value: false },
|
||||
hasOutputNodesSelected: { value: false },
|
||||
hasMultipleSelection: { value: false },
|
||||
computeSelectionFlags: vi.fn(() => ({
|
||||
collapsed: false,
|
||||
pinned: false
|
||||
}))
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/graph/useSelectionState', () => ({
|
||||
useSelectionState: () => selectionState
|
||||
}))
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => canvasState
|
||||
}))
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
getExtraOptionsForWidget: () => extraWidgetOptions.value
|
||||
}))
|
||||
vi.mock('@/composables/graph/useImageMenuOptions', () => ({
|
||||
useImageMenuOptions: () => ({
|
||||
getImageMenuOptions: () => imageOptions.value
|
||||
})
|
||||
}))
|
||||
vi.mock('@/composables/graph/useNodeMenuOptions', () => ({
|
||||
useNodeMenuOptions: () => ({
|
||||
getNodeInfoOption: (openNodeInfo: () => boolean) => ({
|
||||
label: 'Node Info',
|
||||
action: openNodeInfo
|
||||
}),
|
||||
getNodeVisualOptions: () => nodeMenu.visualOptions.value,
|
||||
getPinOption: () => ({ label: 'Pin' }),
|
||||
getBypassOption: () => ({ label: 'Bypass' }),
|
||||
getRunBranchOption: () => ({ label: 'Run Branch' })
|
||||
})
|
||||
}))
|
||||
vi.mock('@/composables/graph/useGroupMenuOptions', () => ({
|
||||
useGroupMenuOptions: () => ({
|
||||
getFitGroupToNodesOption: () => ({ label: 'Fit' }),
|
||||
getGroupColorOptions: () => ({ label: 'Group Color' }),
|
||||
getGroupModeOptions: () => [{ label: 'Group Mode' }]
|
||||
})
|
||||
}))
|
||||
vi.mock('@/composables/graph/useSelectionMenuOptions', () => ({
|
||||
useSelectionMenuOptions: () => ({
|
||||
getBasicSelectionOptions: () => selectionMenu.basicOptions.value,
|
||||
getMultipleNodesOptions: () => selectionMenu.multipleOptions.value,
|
||||
getSubgraphOptions: () => selectionMenu.subgraphOptions.value
|
||||
})
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
registerNodeOptionsInstance(null)
|
||||
canvasState.canvas = undefined
|
||||
extraWidgetOptions.value = []
|
||||
imageOptions.value = []
|
||||
nodeMenu.visualOptions.value = []
|
||||
selectionMenu.basicOptions.value = [{ label: 'Copy' }]
|
||||
selectionMenu.multipleOptions.value = [{ label: 'Align' }]
|
||||
selectionMenu.subgraphOptions.value = []
|
||||
selectionState.selectedItems.value = []
|
||||
selectionState.selectedNodes.value = []
|
||||
selectionState.canOpenNodeInfo.value = false
|
||||
selectionState.hasSubgraphs.value = false
|
||||
selectionState.hasImageNode.value = false
|
||||
selectionState.hasOutputNodesSelected.value = false
|
||||
selectionState.hasMultipleSelection.value = false
|
||||
selectionState.computeSelectionFlags.mockReturnValue({
|
||||
collapsed: false,
|
||||
pinned: false
|
||||
})
|
||||
})
|
||||
|
||||
function labels() {
|
||||
return useMoreOptionsMenu()
|
||||
.menuOptions.value.map((o) => o.label)
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
describe('node options popover instance', () => {
|
||||
it('reports closed when no instance is registered', () => {
|
||||
expect(isNodeOptionsOpen()).toBe(false)
|
||||
})
|
||||
|
||||
it('reflects the registered instance open state and forwards toggle/show', () => {
|
||||
const toggle = vi.fn()
|
||||
const show = vi.fn()
|
||||
registerNodeOptionsInstance({
|
||||
toggle,
|
||||
show,
|
||||
hide: vi.fn(),
|
||||
isOpen: ref(true)
|
||||
})
|
||||
|
||||
expect(isNodeOptionsOpen()).toBe(true)
|
||||
toggleNodeOptions(new Event('click'))
|
||||
showNodeOptions(new MouseEvent('contextmenu'))
|
||||
expect(toggle).toHaveBeenCalled()
|
||||
expect(show).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useMoreOptionsMenu', () => {
|
||||
it('assembles a non-empty menu for a single selected node', () => {
|
||||
const node = { id: 1, widgets: [] }
|
||||
selectionState.selectedItems.value = [node]
|
||||
selectionState.selectedNodes.value = [node]
|
||||
|
||||
expect(labels()).toContain('Copy')
|
||||
expect(labels()).toContain('Pin')
|
||||
})
|
||||
|
||||
it('includes run-branch and multiple-node options for output selections', () => {
|
||||
const nodes = [
|
||||
{ id: 1, widgets: [] },
|
||||
{ id: 2, widgets: [] }
|
||||
]
|
||||
selectionState.selectedItems.value = nodes
|
||||
selectionState.selectedNodes.value = nodes
|
||||
selectionState.hasOutputNodesSelected.value = true
|
||||
selectionState.hasMultipleSelection.value = true
|
||||
|
||||
const menuLabels = labels()
|
||||
expect(menuLabels).toContain('Run Branch')
|
||||
expect(menuLabels).toContain('Align')
|
||||
})
|
||||
|
||||
it('recomputes menu flags after a manual bump', () => {
|
||||
const { bump, menuOptions } = useMoreOptionsMenu()
|
||||
void menuOptions.value
|
||||
expect(selectionState.computeSelectionFlags).toHaveBeenCalledTimes(1)
|
||||
|
||||
bump()
|
||||
void menuOptions.value
|
||||
expect(selectionState.computeSelectionFlags).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('assembles group-context options for a single selected group', () => {
|
||||
const group = new LGraphGroup('Group')
|
||||
selectionState.selectedItems.value = [group]
|
||||
selectionState.selectedNodes.value = []
|
||||
|
||||
const menuLabels = labels()
|
||||
expect(menuLabels).toContain('Group Mode')
|
||||
expect(menuLabels).toContain('Fit')
|
||||
expect(menuLabels).toContain('Group Color')
|
||||
})
|
||||
|
||||
it('includes node info and visual options for a single node', () => {
|
||||
const node = { id: 1, widgets: [] }
|
||||
selectionState.selectedItems.value = [node]
|
||||
selectionState.selectedNodes.value = [node]
|
||||
selectionState.canOpenNodeInfo.value = true
|
||||
nodeMenu.visualOptions.value = [
|
||||
{ label: 'Minimize Node' },
|
||||
{ label: 'Shape', hasSubmenu: true, submenu: [] },
|
||||
{ label: 'Color', hasSubmenu: true, submenu: [] }
|
||||
]
|
||||
|
||||
const menu = useMoreOptionsMenu().menuOptions.value
|
||||
expect(menu.map((o) => o.label)).toEqual(
|
||||
expect.arrayContaining(['Node Info', 'Minimize Node', 'Shape', 'Color'])
|
||||
)
|
||||
menu.find((o) => o.label === 'Node Info')?.action?.()
|
||||
expect(selectionState.openNodeInfo).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns only entries that have populated submenus', () => {
|
||||
const node = { id: 1, widgets: [] }
|
||||
selectionState.selectedItems.value = [node]
|
||||
selectionState.selectedNodes.value = [node]
|
||||
nodeMenu.visualOptions.value = [
|
||||
{ label: 'Minimize Node' },
|
||||
{
|
||||
label: 'Shape',
|
||||
hasSubmenu: true,
|
||||
submenu: [{ label: 'Box', action: vi.fn() }]
|
||||
},
|
||||
{ label: 'Color', hasSubmenu: true }
|
||||
]
|
||||
|
||||
expect(
|
||||
useMoreOptionsMenu().menuOptionsWithSubmenu.value.map((o) => o.label)
|
||||
).toEqual(['Shape'])
|
||||
})
|
||||
|
||||
it('includes image menu options for a selected image node', () => {
|
||||
const node = { id: 1, widgets: [] }
|
||||
selectionState.selectedItems.value = [node]
|
||||
selectionState.selectedNodes.value = [node]
|
||||
selectionState.hasImageNode.value = true
|
||||
imageOptions.value = [{ label: 'Open Image' }]
|
||||
|
||||
expect(labels()).toContain('Open Image')
|
||||
})
|
||||
|
||||
it('merges LiteGraph menu options for a single selected node', () => {
|
||||
const node = { id: 1, widgets: [] }
|
||||
const getNodeMenuOptions = vi.fn(() => [
|
||||
{ content: 'Extension Action', callback: vi.fn() }
|
||||
])
|
||||
selectionState.selectedItems.value = [node]
|
||||
selectionState.selectedNodes.value = [node]
|
||||
canvasState.canvas = { getNodeMenuOptions }
|
||||
|
||||
expect(labels()).toContain('Extension Action')
|
||||
expect(getNodeMenuOptions).toHaveBeenCalledWith(node)
|
||||
})
|
||||
|
||||
it('keeps Vue options when LiteGraph menu construction throws', () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const node = { id: 1, widgets: [] }
|
||||
selectionState.selectedItems.value = [node]
|
||||
selectionState.selectedNodes.value = [node]
|
||||
canvasState.canvas = {
|
||||
getNodeMenuOptions: vi.fn(() => {
|
||||
throw new Error('boom')
|
||||
})
|
||||
}
|
||||
|
||||
expect(labels()).toContain('Copy')
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
'Error getting LiteGraph menu items:',
|
||||
expect.any(Error)
|
||||
)
|
||||
})
|
||||
|
||||
it('adds hovered widget options to the selected node menu', () => {
|
||||
const node = { id: 1, widgets: [{ name: 'image' }] }
|
||||
selectionState.selectedItems.value = [node]
|
||||
selectionState.selectedNodes.value = [node]
|
||||
extraWidgetOptions.value = [{ content: 'Widget Extra', callback: vi.fn() }]
|
||||
|
||||
showNodeOptions(new MouseEvent('contextmenu'), 'image')
|
||||
|
||||
expect(labels()).toContain('Widget Extra')
|
||||
})
|
||||
})
|
||||
@@ -1,175 +0,0 @@
|
||||
import type * as VueI18n from 'vue-i18n'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { useNodeCustomization } from '@/composables/graph/useNodeCustomization'
|
||||
|
||||
const { selection, refreshCanvas, palette } = vi.hoisted(() => ({
|
||||
selection: { items: [] as unknown[] },
|
||||
refreshCanvas: vi.fn(),
|
||||
palette: { light_theme: false }
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof VueI18n>()),
|
||||
useI18n: () => ({ t: (key: string) => key })
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({
|
||||
get selectedItems() {
|
||||
return selection.items
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workspace/colorPaletteStore', () => ({
|
||||
useColorPaletteStore: () => ({
|
||||
get completedActivePalette() {
|
||||
return { light_theme: palette.light_theme }
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/graph/useCanvasRefresh', () => ({
|
||||
useCanvasRefresh: () => ({ refreshCanvas })
|
||||
}))
|
||||
|
||||
function colorable(bgcolor?: string) {
|
||||
return {
|
||||
setColorOption: vi.fn(),
|
||||
getColorOption: () => (bgcolor ? { bgcolor } : null)
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
selection.items = []
|
||||
refreshCanvas.mockReset()
|
||||
palette.light_theme = false
|
||||
})
|
||||
|
||||
describe('useNodeCustomization', () => {
|
||||
it('exposes color and shape option lists', () => {
|
||||
const { colorOptions, shapeOptions } = useNodeCustomization()
|
||||
expect(colorOptions.length).toBeGreaterThan(1)
|
||||
expect(shapeOptions.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('reflects the active palette light-theme flag', () => {
|
||||
palette.light_theme = true
|
||||
expect(useNodeCustomization().isLightTheme.value).toBe(true)
|
||||
})
|
||||
|
||||
it('clears color on all colorable items for the no-color option', () => {
|
||||
const item = colorable()
|
||||
selection.items = [item]
|
||||
useNodeCustomization().applyColor(null)
|
||||
|
||||
expect(item.setColorOption).toHaveBeenCalledWith(null)
|
||||
expect(refreshCanvas).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('applies a named color option to colorable items', () => {
|
||||
const item = colorable()
|
||||
selection.items = [item]
|
||||
const { colorOptions, applyColor } = useNodeCustomization()
|
||||
const named = colorOptions.at(-1)!
|
||||
|
||||
applyColor(named)
|
||||
|
||||
expect(item.setColorOption).toHaveBeenCalledTimes(1)
|
||||
expect(item.setColorOption.mock.calls[0][0]).not.toBeNull()
|
||||
})
|
||||
|
||||
it('skips non-colorable items when applying colors', () => {
|
||||
const item = colorable()
|
||||
selection.items = [{}, item]
|
||||
|
||||
useNodeCustomization().applyColor(null)
|
||||
|
||||
expect(item.setColorOption).toHaveBeenCalledWith(null)
|
||||
expect(refreshCanvas).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns null current color for an empty selection', () => {
|
||||
expect(useNodeCustomization().getCurrentColor()).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null current color when no selected item is colorable', () => {
|
||||
selection.items = [{}]
|
||||
expect(useNodeCustomization().getCurrentColor()).toBeNull()
|
||||
})
|
||||
|
||||
it('reports a recognized current color', () => {
|
||||
const { colorOptions, getCurrentColor } = useNodeCustomization()
|
||||
const named = colorOptions.at(-1)!
|
||||
selection.items = [colorable(named.value.dark)]
|
||||
|
||||
expect(getCurrentColor()?.name).toBe(named.name)
|
||||
})
|
||||
|
||||
it('falls back to the no-color option for an unrecognized current color', () => {
|
||||
selection.items = [colorable('#not-a-known-color')]
|
||||
const result = useNodeCustomization().getCurrentColor()
|
||||
expect(result?.name).toBe('noColor')
|
||||
})
|
||||
|
||||
it('no-ops shape changes when no graph nodes are selected', () => {
|
||||
selection.items = [colorable()]
|
||||
const { applyShape, shapeOptions } = useNodeCustomization()
|
||||
applyShape(shapeOptions[0])
|
||||
expect(refreshCanvas).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns null current shape with no nodes selected', () => {
|
||||
expect(useNodeCustomization().getCurrentShape()).toBeNull()
|
||||
})
|
||||
|
||||
it('applies a shape to selected graph nodes and refreshes', () => {
|
||||
const node = new LGraphNode('Test')
|
||||
selection.items = [node]
|
||||
const { applyShape, shapeOptions } = useNodeCustomization()
|
||||
const target = shapeOptions[0]
|
||||
|
||||
applyShape(target)
|
||||
|
||||
expect(node.shape).toBe(target.value)
|
||||
expect(refreshCanvas).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports the current shape of a selected node', () => {
|
||||
const node = new LGraphNode('Test')
|
||||
const { shapeOptions, getCurrentShape } = useNodeCustomization()
|
||||
node.shape = shapeOptions[0].value
|
||||
selection.items = [node]
|
||||
|
||||
expect(getCurrentShape()?.value).toBe(shapeOptions[0].value)
|
||||
})
|
||||
|
||||
it('uses the default shape when a selected node has no shape', () => {
|
||||
const node = new LGraphNode('Test')
|
||||
Object.defineProperty(node, 'shape', {
|
||||
value: undefined,
|
||||
writable: true,
|
||||
configurable: true
|
||||
})
|
||||
const { shapeOptions, getCurrentShape } = useNodeCustomization()
|
||||
selection.items = [node]
|
||||
|
||||
expect(getCurrentShape()?.value).toBe(shapeOptions[0].value)
|
||||
})
|
||||
|
||||
it('falls back to the default shape for an unknown node shape', () => {
|
||||
const node = new LGraphNode('Test')
|
||||
Object.defineProperty(node, 'shape', {
|
||||
value: 999,
|
||||
writable: true,
|
||||
configurable: true
|
||||
})
|
||||
const { shapeOptions, getCurrentShape } = useNodeCustomization()
|
||||
selection.items = [node]
|
||||
|
||||
expect(getCurrentShape()?.value).toBe(shapeOptions[0].value)
|
||||
})
|
||||
})
|
||||
@@ -10,43 +10,30 @@ import { LGraphEventMode, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
const { actions, customization } = vi.hoisted(() => ({
|
||||
actions: {
|
||||
adjustNodeSize: vi.fn(),
|
||||
toggleNodeCollapse: vi.fn(),
|
||||
toggleNodePin: vi.fn(),
|
||||
toggleNodeBypass: vi.fn(),
|
||||
runBranch: vi.fn()
|
||||
},
|
||||
customization: {
|
||||
shapeOptions: [] as Array<{ localizedName: string; value: string }>,
|
||||
colorOptions: [] as Array<{
|
||||
name: string
|
||||
localizedName: string
|
||||
value: { dark: string; light: string }
|
||||
}>,
|
||||
applyShape: vi.fn(),
|
||||
applyColor: vi.fn(),
|
||||
isLightTheme: { value: false }
|
||||
}
|
||||
}))
|
||||
|
||||
// canvasStore transitively imports the app singleton; stub it so the real
|
||||
// ComfyApp module never loads during these unit tests.
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: { canvas: { selected_nodes: null } }
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/graph/useNodeCustomization', () => ({
|
||||
useNodeCustomization: () => ({
|
||||
shapeOptions: customization.shapeOptions,
|
||||
applyShape: customization.applyShape,
|
||||
applyColor: customization.applyColor,
|
||||
colorOptions: customization.colorOptions,
|
||||
isLightTheme: customization.isLightTheme
|
||||
shapeOptions: [],
|
||||
applyShape: vi.fn(),
|
||||
applyColor: vi.fn(),
|
||||
colorOptions: [],
|
||||
isLightTheme: { value: false }
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/graph/useSelectedNodeActions', () => ({
|
||||
useSelectedNodeActions: () => actions
|
||||
useSelectedNodeActions: () => ({
|
||||
adjustNodeSize: vi.fn(),
|
||||
toggleNodeCollapse: vi.fn(),
|
||||
toggleNodePin: vi.fn(),
|
||||
toggleNodeBypass: vi.fn(),
|
||||
runBranch: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
@@ -82,29 +69,9 @@ const getBypassLabel = (selected: LGraphNode[]): string => {
|
||||
return label
|
||||
}
|
||||
|
||||
function readNodeMenuOptions<T>(
|
||||
read: (options: ReturnType<typeof useNodeMenuOptions>) => T
|
||||
): T {
|
||||
const unread = Symbol('unread')
|
||||
const result: { value: T | typeof unread } = { value: unread }
|
||||
const Wrapper = defineComponent({
|
||||
setup() {
|
||||
result.value = read(useNodeMenuOptions())
|
||||
return () => null
|
||||
}
|
||||
})
|
||||
render(Wrapper, { global: { plugins: [i18n] } })
|
||||
if (result.value === unread) throw new Error('Composable was not read')
|
||||
return result.value
|
||||
}
|
||||
|
||||
describe('useNodeMenuOptions', () => {
|
||||
describe('useNodeMenuOptions.getBypassOption', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setActivePinia(createPinia())
|
||||
customization.shapeOptions = []
|
||||
customization.colorOptions = []
|
||||
customization.isLightTheme.value = false
|
||||
})
|
||||
|
||||
it('labels as "Bypass" when no node is bypassed', () => {
|
||||
@@ -130,109 +97,4 @@ describe('useNodeMenuOptions', () => {
|
||||
])
|
||||
).toBe('contextMenu.Bypass')
|
||||
})
|
||||
|
||||
it('labels visual node options from the collapsed state and bumps after action', () => {
|
||||
const expandBump = vi.fn()
|
||||
const expand = readNodeMenuOptions(
|
||||
({ getNodeVisualOptions }) =>
|
||||
getNodeVisualOptions({ collapsed: true, pinned: false }, expandBump)[0]
|
||||
)
|
||||
expect(expand).toMatchObject({
|
||||
label: 'contextMenu.Expand Node',
|
||||
icon: 'icon-[lucide--maximize-2]'
|
||||
})
|
||||
expand.action?.()
|
||||
expect(actions.toggleNodeCollapse).toHaveBeenCalledTimes(1)
|
||||
expect(expandBump).toHaveBeenCalledTimes(1)
|
||||
|
||||
const minimize = readNodeMenuOptions(
|
||||
({ getNodeVisualOptions }) =>
|
||||
getNodeVisualOptions({ collapsed: false, pinned: false }, vi.fn())[0]
|
||||
)
|
||||
expect(minimize).toMatchObject({
|
||||
label: 'contextMenu.Minimize Node',
|
||||
icon: 'icon-[lucide--minimize-2]'
|
||||
})
|
||||
})
|
||||
|
||||
it('labels pin options from the pinned state and bumps after action', () => {
|
||||
const bump = vi.fn()
|
||||
const unpin = readNodeMenuOptions(({ getPinOption }) =>
|
||||
getPinOption({ collapsed: false, pinned: true }, bump)
|
||||
)
|
||||
expect(unpin).toMatchObject({
|
||||
label: 'contextMenu.Unpin',
|
||||
icon: 'icon-[lucide--pin-off]'
|
||||
})
|
||||
unpin.action?.()
|
||||
expect(actions.toggleNodePin).toHaveBeenCalledTimes(1)
|
||||
expect(bump).toHaveBeenCalledTimes(1)
|
||||
|
||||
const pin = readNodeMenuOptions(({ getPinOption }) =>
|
||||
getPinOption({ collapsed: false, pinned: false }, vi.fn())
|
||||
)
|
||||
expect(pin).toMatchObject({
|
||||
label: 'contextMenu.Pin',
|
||||
icon: 'icon-[lucide--pin]'
|
||||
})
|
||||
})
|
||||
|
||||
it('builds shape and color submenus and applies selected values', () => {
|
||||
customization.shapeOptions = [{ localizedName: 'Box', value: 'box' }]
|
||||
customization.colorOptions = [
|
||||
{
|
||||
name: 'noColor',
|
||||
localizedName: 'No Color',
|
||||
value: { dark: '#000', light: '#fff' }
|
||||
},
|
||||
{
|
||||
name: 'red',
|
||||
localizedName: 'Red',
|
||||
value: { dark: '#111', light: '#eee' }
|
||||
}
|
||||
]
|
||||
|
||||
const { visualOptions, colorSubmenu } = readNodeMenuOptions((options) => ({
|
||||
visualOptions: options.getNodeVisualOptions(
|
||||
{ collapsed: false, pinned: false },
|
||||
vi.fn()
|
||||
),
|
||||
colorSubmenu: options.colorSubmenu.value
|
||||
}))
|
||||
|
||||
expect(visualOptions[1].submenu).toEqual([
|
||||
expect.objectContaining({ label: 'Box' })
|
||||
])
|
||||
visualOptions[1].submenu?.[0].action()
|
||||
expect(customization.applyShape).toHaveBeenCalledWith(
|
||||
customization.shapeOptions[0]
|
||||
)
|
||||
|
||||
expect(colorSubmenu).toEqual([
|
||||
expect.objectContaining({ label: 'No Color', color: '#000' }),
|
||||
expect.objectContaining({ label: 'Red', color: '#111' })
|
||||
])
|
||||
colorSubmenu[0].action()
|
||||
colorSubmenu[1].action()
|
||||
expect(customization.applyColor).toHaveBeenNthCalledWith(1, null)
|
||||
expect(customization.applyColor).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
customization.colorOptions[1]
|
||||
)
|
||||
})
|
||||
|
||||
it('uses light-theme colors for the color submenu', () => {
|
||||
customization.isLightTheme.value = true
|
||||
customization.colorOptions = [
|
||||
{
|
||||
name: 'red',
|
||||
localizedName: 'Red',
|
||||
value: { dark: '#111', light: '#eee' }
|
||||
}
|
||||
]
|
||||
|
||||
expect(
|
||||
readNodeMenuOptions((options) => options.colorSubmenu.value[0].color)
|
||||
).toBe('#eee')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,221 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useSelectionOperations } from '@/composables/graph/useSelectionOperations'
|
||||
|
||||
const {
|
||||
canvas,
|
||||
toastAdd,
|
||||
captureCanvasState,
|
||||
updateSelectedItems,
|
||||
prompt,
|
||||
titleEditor,
|
||||
store
|
||||
} = vi.hoisted(() => ({
|
||||
canvas: {
|
||||
selectedItems: new Set<unknown>(),
|
||||
copyToClipboard: vi.fn(),
|
||||
pasteFromClipboard: vi.fn(),
|
||||
deleteSelected: vi.fn(),
|
||||
setDirty: vi.fn()
|
||||
},
|
||||
toastAdd: vi.fn(),
|
||||
captureCanvasState: vi.fn(),
|
||||
updateSelectedItems: vi.fn(),
|
||||
prompt: vi.fn(),
|
||||
titleEditor: { titleEditorTarget: null as unknown },
|
||||
store: { selectedItems: [] as unknown[] }
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({ app: { canvas } }))
|
||||
vi.mock('@/i18n', () => ({ t: (key: string) => key }))
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: () => ({ add: toastAdd })
|
||||
}))
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
activeWorkflow: { changeTracker: { captureCanvasState } }
|
||||
})
|
||||
}))
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({
|
||||
updateSelectedItems,
|
||||
get selectedItems() {
|
||||
return store.selectedItems
|
||||
}
|
||||
}),
|
||||
useTitleEditorStore: () => titleEditor
|
||||
}))
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
useDialogService: () => ({ prompt })
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
canvas.selectedItems = new Set()
|
||||
canvas.copyToClipboard.mockReset()
|
||||
canvas.pasteFromClipboard.mockReset()
|
||||
canvas.deleteSelected.mockReset()
|
||||
canvas.setDirty.mockReset()
|
||||
toastAdd.mockReset()
|
||||
captureCanvasState.mockReset()
|
||||
updateSelectedItems.mockReset()
|
||||
prompt.mockReset()
|
||||
titleEditor.titleEditorTarget = null
|
||||
store.selectedItems = []
|
||||
})
|
||||
|
||||
describe('useSelectionOperations', () => {
|
||||
it('warns and does nothing when copying an empty selection', () => {
|
||||
useSelectionOperations().copySelection()
|
||||
expect(canvas.copyToClipboard).not.toHaveBeenCalled()
|
||||
expect(toastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'warn' })
|
||||
)
|
||||
})
|
||||
|
||||
it('copies a non-empty selection and reports success', () => {
|
||||
canvas.selectedItems = new Set(['a'])
|
||||
useSelectionOperations().copySelection()
|
||||
expect(canvas.copyToClipboard).toHaveBeenCalled()
|
||||
expect(toastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'success' })
|
||||
)
|
||||
})
|
||||
|
||||
it('pastes from clipboard and captures canvas state', () => {
|
||||
useSelectionOperations().pasteSelection()
|
||||
expect(canvas.pasteFromClipboard).toHaveBeenCalledWith({
|
||||
connectInputs: false
|
||||
})
|
||||
expect(captureCanvasState).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('duplicates by copy, clear, paste', () => {
|
||||
canvas.selectedItems = new Set(['a'])
|
||||
useSelectionOperations().duplicateSelection()
|
||||
expect(canvas.copyToClipboard).toHaveBeenCalled()
|
||||
expect(canvas.selectedItems.size).toBe(0)
|
||||
expect(updateSelectedItems).toHaveBeenCalled()
|
||||
expect(canvas.pasteFromClipboard).toHaveBeenCalled()
|
||||
expect(captureCanvasState).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('warns when duplicating nothing', () => {
|
||||
useSelectionOperations().duplicateSelection()
|
||||
expect(canvas.copyToClipboard).not.toHaveBeenCalled()
|
||||
expect(toastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'warn' })
|
||||
)
|
||||
})
|
||||
|
||||
it('deletes a non-empty selection and marks the canvas dirty', () => {
|
||||
canvas.selectedItems = new Set(['a'])
|
||||
useSelectionOperations().deleteSelection()
|
||||
expect(canvas.deleteSelected).toHaveBeenCalled()
|
||||
expect(canvas.setDirty).toHaveBeenCalledWith(true, true)
|
||||
expect(captureCanvasState).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('warns when deleting nothing', () => {
|
||||
useSelectionOperations().deleteSelection()
|
||||
expect(canvas.deleteSelected).not.toHaveBeenCalled()
|
||||
expect(toastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'warn' })
|
||||
)
|
||||
})
|
||||
|
||||
it('routes a single node rename to the title editor', async () => {
|
||||
const node = new LGraphNode('Test')
|
||||
store.selectedItems = [node]
|
||||
|
||||
await useSelectionOperations().renameSelection()
|
||||
|
||||
expect(titleEditor.titleEditorTarget).toBe(node)
|
||||
expect(prompt).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renames a single non-node item via the prompt dialog', async () => {
|
||||
const group = { title: 'Old' }
|
||||
store.selectedItems = [group]
|
||||
prompt.mockResolvedValue('New')
|
||||
|
||||
await useSelectionOperations().renameSelection()
|
||||
|
||||
expect(group.title).toBe('New')
|
||||
expect(canvas.setDirty).toHaveBeenCalledWith(true, true)
|
||||
expect(captureCanvasState).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves a single titled item unchanged when the prompt returns the same title', async () => {
|
||||
const group = { title: 'Old' }
|
||||
store.selectedItems = [group]
|
||||
prompt.mockResolvedValue('Old')
|
||||
|
||||
await useSelectionOperations().renameSelection()
|
||||
|
||||
expect(group.title).toBe('Old')
|
||||
expect(canvas.setDirty).not.toHaveBeenCalled()
|
||||
expect(captureCanvasState).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not assign a title to a selected item without a title property', async () => {
|
||||
const item = {}
|
||||
store.selectedItems = [item]
|
||||
prompt.mockResolvedValue('New')
|
||||
|
||||
await useSelectionOperations().renameSelection()
|
||||
|
||||
expect(item).toEqual({})
|
||||
expect(canvas.setDirty).not.toHaveBeenCalled()
|
||||
expect(captureCanvasState).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('batch-renames multiple items with an indexed base name', async () => {
|
||||
const a = { title: 'a' }
|
||||
const b = { title: 'b' }
|
||||
store.selectedItems = [a, b]
|
||||
prompt.mockResolvedValue('Item')
|
||||
|
||||
await useSelectionOperations().renameSelection()
|
||||
|
||||
expect(a.title).toBe('Item 1')
|
||||
expect(b.title).toBe('Item 2')
|
||||
expect(canvas.setDirty).toHaveBeenCalledWith(true, true)
|
||||
expect(captureCanvasState).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips untitled items during batch rename', async () => {
|
||||
const a = { title: 'a' }
|
||||
const b = {}
|
||||
store.selectedItems = [a, b]
|
||||
prompt.mockResolvedValue('Item')
|
||||
|
||||
await useSelectionOperations().renameSelection()
|
||||
|
||||
expect(a.title).toBe('Item 1')
|
||||
expect(b).toEqual({})
|
||||
expect(canvas.setDirty).toHaveBeenCalledWith(true, true)
|
||||
expect(captureCanvasState).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves a multiple selection unchanged when batch rename is cancelled', async () => {
|
||||
const a = { title: 'a' }
|
||||
const b = { title: 'b' }
|
||||
store.selectedItems = [a, b]
|
||||
prompt.mockResolvedValue('')
|
||||
|
||||
await useSelectionOperations().renameSelection()
|
||||
|
||||
expect(a.title).toBe('a')
|
||||
expect(b.title).toBe('b')
|
||||
expect(canvas.setDirty).not.toHaveBeenCalled()
|
||||
expect(captureCanvasState).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('warns when renaming an empty selection', async () => {
|
||||
await useSelectionOperations().renameSelection()
|
||||
expect(toastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'warn' })
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -8,12 +8,7 @@ import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import {
|
||||
isImageNode,
|
||||
isLGraphGroup,
|
||||
isLGraphNode,
|
||||
isLoad3dNode
|
||||
} from '@/utils/litegraphUtil'
|
||||
import { isImageNode, isLGraphNode } from '@/utils/litegraphUtil'
|
||||
import { filterOutputNodes } from '@/utils/nodeFilterUtil'
|
||||
import {
|
||||
createMockLGraphNode,
|
||||
@@ -22,9 +17,7 @@ import {
|
||||
|
||||
vi.mock('@/utils/litegraphUtil', () => ({
|
||||
isLGraphNode: vi.fn(),
|
||||
isImageNode: vi.fn(),
|
||||
isLGraphGroup: vi.fn(),
|
||||
isLoad3dNode: vi.fn()
|
||||
isImageNode: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/nodeFilterUtil', () => ({
|
||||
@@ -103,14 +96,6 @@ describe('useSelectionState', () => {
|
||||
const typedNode = node as { type?: string }
|
||||
return typedNode?.type === 'ImageNode'
|
||||
})
|
||||
vi.mocked(isLGraphGroup).mockImplementation((item: unknown) => {
|
||||
const typedItem = item as { isGroup?: boolean }
|
||||
return typedItem?.isGroup === true
|
||||
})
|
||||
vi.mocked(isLoad3dNode).mockImplementation((node: unknown) => {
|
||||
const typedNode = node as { type?: string }
|
||||
return typedNode?.type === 'Load3D'
|
||||
})
|
||||
vi.mocked(filterOutputNodes).mockImplementation((nodes) =>
|
||||
nodes.filter((n) => n.type === 'OutputNode')
|
||||
)
|
||||
@@ -150,21 +135,6 @@ describe('useSelectionState', () => {
|
||||
const { hasMultipleSelection } = useSelectionState()
|
||||
expect(hasMultipleSelection.value).toBe(false)
|
||||
})
|
||||
|
||||
test('hasGroupedNodesSelection should detect a group containing nodes', () => {
|
||||
const canvasStore = useCanvasStore()
|
||||
const graphNode = createMockLGraphNode({ id: 2 })
|
||||
const group = createMockPositionable({ id: 2000 })
|
||||
Object.assign(group, {
|
||||
isGroup: true,
|
||||
isNode: false,
|
||||
children: new Set([graphNode])
|
||||
})
|
||||
canvasStore.$state.selectedItems = [group]
|
||||
|
||||
const { hasGroupedNodesSelection } = useSelectionState()
|
||||
expect(hasGroupedNodesSelection.value).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Node Type Filtering', () => {
|
||||
@@ -245,13 +215,6 @@ describe('useSelectionState', () => {
|
||||
const newIsPinned = newSelectedNodes.value.some((n) => n.pinned === true)
|
||||
expect(newIsPinned).toBe(false)
|
||||
})
|
||||
|
||||
test('should compute default flags for an empty node selection', () => {
|
||||
expect(useSelectionState().computeSelectionFlags()).toEqual({
|
||||
collapsed: false,
|
||||
pinned: false
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Node Info', () => {
|
||||
|
||||
@@ -4,45 +4,34 @@ import { LGraphNode, SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
publishSubgraph: vi.fn(),
|
||||
selectedItems: [] as unknown[],
|
||||
getSelectedNodes: vi.fn((): unknown[] => []),
|
||||
getCanvas: vi.fn(),
|
||||
updateSelectedItems: vi.fn(),
|
||||
revokeSubgraphPreviews: vi.fn(),
|
||||
activeWorkflow: null as null | {
|
||||
changeTracker?: {
|
||||
captureCanvasState: () => void
|
||||
}
|
||||
}
|
||||
selectedItems: [] as unknown[]
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/canvas/useSelectedLiteGraphItems', () => ({
|
||||
useSelectedLiteGraphItems: () => ({
|
||||
getSelectedNodes: mocks.getSelectedNodes
|
||||
getSelectedNodes: vi.fn(() => [])
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({
|
||||
getCanvas: mocks.getCanvas,
|
||||
getCanvas: vi.fn(),
|
||||
get selectedItems() {
|
||||
return mocks.selectedItems
|
||||
},
|
||||
updateSelectedItems: mocks.updateSelectedItems
|
||||
updateSelectedItems: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
get activeWorkflow() {
|
||||
return mocks.activeWorkflow
|
||||
}
|
||||
activeWorkflow: null
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/nodeOutputStore', () => ({
|
||||
useNodeOutputStore: () => ({
|
||||
revokeSubgraphPreviews: mocks.revokeSubgraphPreviews
|
||||
revokeSubgraphPreviews: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -61,36 +50,10 @@ function createRegularNode(): LGraphNode {
|
||||
return new LGraphNode('testnode')
|
||||
}
|
||||
|
||||
function createCanvas({
|
||||
graph,
|
||||
subgraph,
|
||||
selectedItems = []
|
||||
}: {
|
||||
graph?: {
|
||||
convertToSubgraph?: ReturnType<typeof vi.fn>
|
||||
unpackSubgraph?: ReturnType<typeof vi.fn>
|
||||
}
|
||||
subgraph?: {
|
||||
convertToSubgraph?: ReturnType<typeof vi.fn>
|
||||
unpackSubgraph?: ReturnType<typeof vi.fn>
|
||||
}
|
||||
selectedItems?: unknown[]
|
||||
} = {}) {
|
||||
return {
|
||||
graph,
|
||||
subgraph,
|
||||
selectedItems: new Set(selectedItems),
|
||||
select: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
describe('useSubgraphOperations', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.selectedItems = []
|
||||
mocks.getSelectedNodes.mockReturnValue([])
|
||||
mocks.getCanvas.mockReturnValue(createCanvas())
|
||||
mocks.activeWorkflow = null
|
||||
})
|
||||
|
||||
it('addSubgraphToLibrary calls publishSubgraph when single SubgraphNode selected', async () => {
|
||||
@@ -140,126 +103,4 @@ describe('useSubgraphOperations', () => {
|
||||
|
||||
expect(mocks.publishSubgraph).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports selected subgraph and selectable node state', async () => {
|
||||
mocks.selectedItems = [createRegularNode()]
|
||||
mocks.getSelectedNodes.mockReturnValue([])
|
||||
|
||||
const { useSubgraphOperations } =
|
||||
await import('@/composables/graph/useSubgraphOperations')
|
||||
const { isSubgraphSelected, hasSelectableNodes } = useSubgraphOperations()
|
||||
|
||||
expect(isSubgraphSelected()).toBe(false)
|
||||
expect(hasSelectableNodes()).toBe(false)
|
||||
|
||||
mocks.selectedItems = [createSubgraphNode()]
|
||||
mocks.getSelectedNodes.mockReturnValue([createRegularNode()])
|
||||
|
||||
expect(isSubgraphSelected()).toBe(true)
|
||||
expect(hasSelectableNodes()).toBe(true)
|
||||
})
|
||||
|
||||
it('converts selected items to a subgraph and captures workflow state', async () => {
|
||||
const captureCanvasState = vi.fn()
|
||||
const node = createSubgraphNode()
|
||||
const graph = {
|
||||
convertToSubgraph: vi.fn(() => ({ node })),
|
||||
unpackSubgraph: vi.fn()
|
||||
}
|
||||
const canvas = createCanvas({
|
||||
graph,
|
||||
selectedItems: [createRegularNode()]
|
||||
})
|
||||
mocks.getCanvas.mockReturnValue(canvas)
|
||||
mocks.activeWorkflow = {
|
||||
changeTracker: {
|
||||
captureCanvasState
|
||||
}
|
||||
}
|
||||
|
||||
const { useSubgraphOperations } =
|
||||
await import('@/composables/graph/useSubgraphOperations')
|
||||
const { convertToSubgraph } = useSubgraphOperations()
|
||||
|
||||
convertToSubgraph()
|
||||
|
||||
expect(graph.convertToSubgraph).toHaveBeenCalledWith(canvas.selectedItems)
|
||||
expect(canvas.select).toHaveBeenCalledWith(node)
|
||||
expect(mocks.updateSelectedItems).toHaveBeenCalledOnce()
|
||||
expect(captureCanvasState).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not select or capture when conversion has no graph or no result', async () => {
|
||||
const graph = {
|
||||
convertToSubgraph: vi.fn(() => null),
|
||||
unpackSubgraph: vi.fn()
|
||||
}
|
||||
const canvas = createCanvas({ graph })
|
||||
mocks.getCanvas
|
||||
.mockReturnValueOnce(createCanvas())
|
||||
.mockReturnValueOnce(canvas)
|
||||
|
||||
const { useSubgraphOperations } =
|
||||
await import('@/composables/graph/useSubgraphOperations')
|
||||
const { convertToSubgraph } = useSubgraphOperations()
|
||||
|
||||
expect(convertToSubgraph()).toBeNull()
|
||||
expect(convertToSubgraph()).toBeUndefined()
|
||||
expect(canvas.select).not.toHaveBeenCalled()
|
||||
expect(mocks.updateSelectedItems).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('unpacks selected subgraph nodes from the active graph and revokes previews', async () => {
|
||||
const captureCanvasState = vi.fn()
|
||||
const subgraphNode = createSubgraphNode()
|
||||
const graph = {
|
||||
convertToSubgraph: vi.fn(),
|
||||
unpackSubgraph: vi.fn()
|
||||
}
|
||||
mocks.getCanvas.mockReturnValue(
|
||||
createCanvas({
|
||||
subgraph: graph,
|
||||
selectedItems: [subgraphNode, createRegularNode()]
|
||||
})
|
||||
)
|
||||
mocks.activeWorkflow = {
|
||||
changeTracker: {
|
||||
captureCanvasState
|
||||
}
|
||||
}
|
||||
|
||||
const { useSubgraphOperations } =
|
||||
await import('@/composables/graph/useSubgraphOperations')
|
||||
const { unpackSubgraph } = useSubgraphOperations()
|
||||
|
||||
unpackSubgraph()
|
||||
|
||||
expect(mocks.revokeSubgraphPreviews).toHaveBeenCalledWith(subgraphNode)
|
||||
expect(graph.unpackSubgraph).toHaveBeenCalledWith(subgraphNode, {
|
||||
skipMissingNodes: true
|
||||
})
|
||||
expect(captureCanvasState).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not unpack when no graph or no subgraph nodes are selected', async () => {
|
||||
const graph = {
|
||||
convertToSubgraph: vi.fn(),
|
||||
unpackSubgraph: vi.fn()
|
||||
}
|
||||
mocks.getCanvas
|
||||
.mockReturnValueOnce(createCanvas())
|
||||
.mockReturnValueOnce(
|
||||
createCanvas({ graph, selectedItems: [createRegularNode()] })
|
||||
)
|
||||
|
||||
const { useSubgraphOperations } =
|
||||
await import('@/composables/graph/useSubgraphOperations')
|
||||
const { unpackSubgraph } = useSubgraphOperations()
|
||||
|
||||
unpackSubgraph()
|
||||
unpackSubgraph()
|
||||
|
||||
expect(graph.unpackSubgraph).not.toHaveBeenCalled()
|
||||
expect(mocks.revokeSubgraphPreviews).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||