mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-08 08:08:04 +00:00
Compare commits
2 Commits
codex/cove
...
matt/be-22
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8126713177 | ||
|
|
6b61a560f7 |
2
.github/workflows/ci-tests-e2e-coverage.yaml
vendored
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 \
|
||||
--ignore-errors source,unmapped,range \
|
||||
--synthesize-missing
|
||||
|
||||
- name: Upload HTML report artifact
|
||||
|
||||
@@ -70,4 +70,39 @@ test.describe('Customer story detail @smoke', () => {
|
||||
'/customers/series-entertainment'
|
||||
)
|
||||
})
|
||||
|
||||
test('renders a Creative Campus story with its education blocks', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('/customers/xindi-zhang')
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
level: 1,
|
||||
name: /The tool that expands my art/i
|
||||
})
|
||||
).toBeVisible()
|
||||
|
||||
const nav = page.getByRole('navigation', { name: 'Category filter' })
|
||||
await expect(nav.getByRole('button', { name: 'INTRO' })).toBeVisible()
|
||||
await expect(nav.getByRole('button', { name: 'AT A GLANCE' })).toBeVisible()
|
||||
|
||||
// At a glance block (AtAGlance component) with its spec rows.
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'At a glance' })
|
||||
).toBeVisible()
|
||||
await expect(page.getByText('Program', { exact: true })).toBeVisible()
|
||||
|
||||
// Workflow download button (Download component).
|
||||
await expect(
|
||||
page.getByRole('link', {
|
||||
name: /Download Xindi's style transfer workflow/i
|
||||
})
|
||||
).toBeVisible()
|
||||
|
||||
// Shared education call to action (EducationCta component).
|
||||
await expect(
|
||||
page.getByRole('link', { name: /Explore the Education Program/i })
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,16 +4,24 @@ import { render } from 'astro:content'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import type { CustomerStoryEntry } from '../../utils/customers'
|
||||
import ArticleNav from './ArticleNav.vue'
|
||||
import AtAGlance from './content/AtAGlance.astro'
|
||||
import AuthorBio from './content/AuthorBio.astro'
|
||||
import BulletList from './content/BulletList.astro'
|
||||
import Contributors from './content/Contributors.astro'
|
||||
import Download from './content/Download.astro'
|
||||
import EducationCta from './content/EducationCta.astro'
|
||||
import Embed from './content/Embed.astro'
|
||||
import Figure from './content/Figure.astro'
|
||||
import Heading from './content/Heading.astro'
|
||||
import Heading4 from './content/Heading4.astro'
|
||||
import Link from './content/Link.astro'
|
||||
import ListItem from './content/ListItem.astro'
|
||||
import Paragraph from './content/Paragraph.astro'
|
||||
import Quote from './content/Quote.astro'
|
||||
import ReadMore from './content/ReadMore.vue'
|
||||
import Section from './content/Section.astro'
|
||||
import Steps from './content/Steps.astro'
|
||||
import Video from './content/Video.astro'
|
||||
|
||||
interface Props {
|
||||
entry: CustomerStoryEntry
|
||||
@@ -34,18 +42,26 @@ const categories = entry.data.sections.map((section) => ({
|
||||
// components (Section, Figure, ...) are used directly inside the MDX body.
|
||||
const contentComponents = {
|
||||
p: Paragraph,
|
||||
a: Link,
|
||||
h3: Heading,
|
||||
h4: Heading4,
|
||||
ul: BulletList,
|
||||
li: ListItem,
|
||||
Section,
|
||||
Figure,
|
||||
Quote,
|
||||
Contributors,
|
||||
Steps
|
||||
Steps,
|
||||
AtAGlance,
|
||||
AuthorBio,
|
||||
Download,
|
||||
EducationCta,
|
||||
Embed,
|
||||
Video
|
||||
}
|
||||
---
|
||||
|
||||
<section class="px-4 pt-8 pb-24 lg:px-20 lg:pt-24 lg:pb-40">
|
||||
<section class="max-w-9xl mx-auto px-4 pt-8 pb-24 lg:px-20 lg:pt-24 lg:pb-40">
|
||||
<div class="lg:flex lg:gap-16">
|
||||
<aside class="hidden scrollbar-none lg:block lg:w-48 lg:shrink-0">
|
||||
<div class="sticky top-32">
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
interface Row {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
rows: Row[]
|
||||
}
|
||||
|
||||
const { rows } = Astro.props
|
||||
---
|
||||
|
||||
<div
|
||||
class="my-8 overflow-hidden rounded-2xl border border-white/10 bg-site-bg-soft"
|
||||
>
|
||||
<dl class="divide-y divide-white/10">
|
||||
{
|
||||
rows.map((row) => (
|
||||
<div class="flex flex-col gap-1 p-5 sm:flex-row sm:gap-6">
|
||||
<dt class="text-primary-comfy-yellow shrink-0 text-xs font-bold tracking-widest uppercase sm:w-44">
|
||||
{row.label}
|
||||
</dt>
|
||||
<dd class="text-sm/relaxed text-primary-comfy-canvas">{row.value}</dd>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</dl>
|
||||
</div>
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
interface Author {
|
||||
name?: string
|
||||
role?: string
|
||||
photo?: string
|
||||
bio?: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
label?: string
|
||||
people: Author[]
|
||||
}
|
||||
|
||||
const { label, people } = Astro.props
|
||||
const hasBioSlot = Astro.slots.has('default')
|
||||
---
|
||||
|
||||
<div class="mt-12 border-t border-white/10 pt-8">
|
||||
{
|
||||
label && (
|
||||
<span class="text-primary-comfy-yellow text-xs font-bold tracking-widest uppercase">
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
<div class="mt-4 space-y-8">
|
||||
{
|
||||
people.map((person) => (
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:gap-6">
|
||||
{person.photo && (
|
||||
<img
|
||||
src={person.photo}
|
||||
alt={person.name ?? ''}
|
||||
class="size-20 shrink-0 rounded-full object-cover"
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
{person.name && (
|
||||
<p class="text-sm font-semibold text-primary-comfy-canvas">
|
||||
{person.name}
|
||||
{person.role && (
|
||||
<span class="text-primary-warm-gray"> · {person.role}</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{person.bio ? (
|
||||
<p class="mt-2 text-sm/relaxed text-primary-comfy-canvas italic">
|
||||
{person.bio}
|
||||
</p>
|
||||
) : hasBioSlot ? (
|
||||
<p class="mt-2 text-sm/relaxed text-primary-comfy-canvas italic">
|
||||
<slot />
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -14,7 +14,7 @@ interface Props {
|
||||
const { label, people } = Astro.props
|
||||
---
|
||||
|
||||
<div class="mt-8 rounded-2xl bg-(--site-bg-soft) p-6">
|
||||
<div class="mt-8 rounded-2xl bg-site-bg-soft p-6">
|
||||
<span
|
||||
class="text-primary-comfy-yellow text-xs font-bold tracking-widest uppercase"
|
||||
>
|
||||
|
||||
19
apps/website/src/components/customers/content/Download.astro
Normal file
19
apps/website/src/components/customers/content/Download.astro
Normal file
@@ -0,0 +1,19 @@
|
||||
---
|
||||
interface Props {
|
||||
href: string
|
||||
label: string
|
||||
newTab?: boolean
|
||||
}
|
||||
|
||||
const { href, label, newTab = false } = Astro.props
|
||||
---
|
||||
|
||||
<a
|
||||
href={href}
|
||||
download={newTab ? undefined : true}
|
||||
target={newTab ? '_blank' : undefined}
|
||||
rel={newTab ? 'noopener noreferrer' : undefined}
|
||||
class="text-primary-comfy-yellow my-4 inline-block text-sm font-semibold underline underline-offset-2 transition-opacity hover:opacity-80"
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
import Link from './Link.astro'
|
||||
---
|
||||
|
||||
<div
|
||||
class="border-primary-comfy-yellow mt-12 rounded-2xl border-l-4 bg-site-bg-soft p-8"
|
||||
>
|
||||
<p class="text-base/relaxed text-primary-comfy-canvas">
|
||||
<strong class="font-semibold">Teaching with ComfyUI?</strong> The Comfy Education
|
||||
Program is live: educational pricing, classroom cloud accounts on one invoice,
|
||||
<Link href="https://comfy.org/education">Explore the Education Program</Link> or
|
||||
<Link href="https://tally.so/r/Xx97lL">apply to be a part of the Creative
|
||||
Campus program</Link> if you're interested in exploring a deeper partnership with Comfy.
|
||||
</p>
|
||||
</div>
|
||||
22
apps/website/src/components/customers/content/Embed.astro
Normal file
22
apps/website/src/components/customers/content/Embed.astro
Normal file
@@ -0,0 +1,22 @@
|
||||
---
|
||||
interface Props {
|
||||
src: string
|
||||
title: string
|
||||
}
|
||||
|
||||
const { src, title } = Astro.props
|
||||
---
|
||||
|
||||
<div
|
||||
class="my-8 aspect-video overflow-hidden rounded-2xl border border-white/10 bg-black"
|
||||
>
|
||||
<iframe
|
||||
src={src}
|
||||
title={title}
|
||||
class="size-full"
|
||||
loading="lazy"
|
||||
allow="autoplay; fullscreen; picture-in-picture; clipboard-write"
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
sandbox="allow-scripts allow-same-origin allow-presentation allow-popups"
|
||||
></iframe>
|
||||
</div>
|
||||
@@ -6,14 +6,15 @@ interface Props {
|
||||
}
|
||||
|
||||
const { src, alt, caption } = Astro.props
|
||||
const hasCaptionSlot = Astro.slots.has('default')
|
||||
---
|
||||
|
||||
<figure class="my-8">
|
||||
<img src={src} alt={alt} class="w-full rounded-2xl object-cover" />
|
||||
{
|
||||
caption && (
|
||||
(hasCaptionSlot || caption) && (
|
||||
<figcaption class="mt-3 text-xs text-primary-comfy-canvas">
|
||||
{caption}
|
||||
{hasCaptionSlot ? <slot /> : caption}
|
||||
</figcaption>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
---
|
||||
|
||||
<h4 class="mt-6 mb-2 text-base font-semibold text-primary-comfy-canvas">
|
||||
<slot />
|
||||
</h4>
|
||||
15
apps/website/src/components/customers/content/Link.astro
Normal file
15
apps/website/src/components/customers/content/Link.astro
Normal file
@@ -0,0 +1,15 @@
|
||||
---
|
||||
interface Props {
|
||||
href: string
|
||||
}
|
||||
|
||||
const { href } = Astro.props
|
||||
const isExternal = /^https?:\/\//.test(href)
|
||||
---
|
||||
|
||||
<a
|
||||
href={href}
|
||||
target={isExternal ? '_blank' : undefined}
|
||||
rel={isExternal ? 'noopener noreferrer' : undefined}
|
||||
class="text-primary-comfy-yellow underline underline-offset-2 transition-opacity hover:opacity-80"
|
||||
><slot /></a>
|
||||
@@ -1,16 +1,20 @@
|
||||
---
|
||||
interface Props {
|
||||
name: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
const { name } = Astro.props
|
||||
---
|
||||
|
||||
<blockquote
|
||||
class="border-primary-comfy-yellow my-8 rounded-2xl border-l-4 bg-(--site-bg-soft) p-8"
|
||||
class="border-primary-comfy-yellow my-8 rounded-2xl border-l-4 bg-site-bg-soft p-8"
|
||||
>
|
||||
<p class="text-lg/relaxed font-light text-primary-comfy-canvas italic">
|
||||
"<slot />"
|
||||
</p>
|
||||
<p class="text-primary-comfy-yellow mt-4 text-sm font-semibold">{name}</p>
|
||||
{
|
||||
name && (
|
||||
<p class="text-primary-comfy-yellow mt-4 text-sm font-semibold">{name}</p>
|
||||
)
|
||||
}
|
||||
</blockquote>
|
||||
|
||||
22
apps/website/src/components/customers/content/Video.astro
Normal file
22
apps/website/src/components/customers/content/Video.astro
Normal file
@@ -0,0 +1,22 @@
|
||||
---
|
||||
import VideoPlayer from '../../common/VideoPlayer.vue'
|
||||
|
||||
interface Props {
|
||||
src: string
|
||||
poster?: string
|
||||
caption?: string
|
||||
}
|
||||
|
||||
const { src, poster, caption } = Astro.props
|
||||
---
|
||||
|
||||
<figure class="my-8">
|
||||
<VideoPlayer src={src} poster={poster} client:visible />
|
||||
{
|
||||
caption && (
|
||||
<figcaption class="mt-3 text-xs text-primary-comfy-canvas">
|
||||
{caption}
|
||||
</figcaption>
|
||||
)
|
||||
}
|
||||
</figure>
|
||||
@@ -63,8 +63,12 @@ function bodySectionIds(body: string): string[] {
|
||||
|
||||
const stories = loadStories()
|
||||
|
||||
it('finds all ten customer stories', () => {
|
||||
expect(stories).toHaveLength(10)
|
||||
it('finds customer stories in every locale', () => {
|
||||
for (const locale of locales) {
|
||||
const prefix = `${locale}/`
|
||||
const inLocale = stories.filter((story) => story.file.startsWith(prefix))
|
||||
expect(inLocale.length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
describe.for(stories)('$file', ({ frontmatter, body }) => {
|
||||
|
||||
148
apps/website/src/content/customers/en/golan-levin.mdx
Normal file
148
apps/website/src/content/customers/en/golan-levin.mdx
Normal file
@@ -0,0 +1,148 @@
|
||||
---
|
||||
title: "Seeing the world in new ways: how Prof. Golan Levin teaches with ComfyUI at Carnegie Mellon University"
|
||||
category: "CREATIVE CAMPUS SHOWCASE"
|
||||
description: "\"For me, ComfyUI is not just about generative AI. It's an image-processing workstation for completely new kinds of work.\""
|
||||
cover: "https://media.comfy.org/website/customers/golan-levin/cover.png"
|
||||
order: 7
|
||||
sections:
|
||||
- id: topic-1
|
||||
label: "INTRO"
|
||||
- id: topic-2
|
||||
label: "WHERE COMFYUI FITS"
|
||||
- id: topic-3
|
||||
label: "IMAGE SYNTHESIS"
|
||||
- id: topic-4
|
||||
label: "IMAGE ANALYSIS"
|
||||
- id: topic-5
|
||||
label: "THE CV LAB"
|
||||
- id: topic-6
|
||||
label: "AT A GLANCE"
|
||||
- id: topic-7
|
||||
label: "STUDENT WORK"
|
||||
---
|
||||
|
||||
<Section id="topic-1">
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/golan-levin/augmented-hand.jpg" alt="Golan Levin, Augmented Hand Series" caption="Golan Levin, Augmented Hand Series (2014), with Chris Sugrue and Kyle McDonald. Photo: Gerlinde de Geus, courtesy Cinekid." />
|
||||
|
||||
For many people, AI in the arts means image generation. But Levin has spent much of the past two decades teaching artists how computers can interpret, analyze, and measure the visual world. His own artworks have long explored machine perception through real-time computer vision systems, and since 2024 he has increasingly used ComfyUI to teach these principles.
|
||||
|
||||
For Levin, ComfyUI is less an image generator than an image-processing workbench. Students use it to assemble custom workflows for segmentation, tracking, depth estimation, and other forms of computational perception. The result is an environment where artists can experiment directly with research-grade machine learning tools and combine them into systems of their own design.
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-2">
|
||||
|
||||
### Where does ComfyUI fit in what you're trying to do?
|
||||
|
||||
I'm training creative technologists and technologically literate artists. The typical student in my Creative Coding class is a true hybrid: an art or design undergraduate who is also studying computer science, human-computer interaction, or information science. They have strong visual abilities, strong cultural literacy, and strong algorithmic thinking skills, but my course may be the first time they've had the opportunity to bring those together.
|
||||
|
||||
To me, that means giving students tools they can understand, modify, and remix to make systems of their own design, rather than treating creative software as a fixed given. That's why I'm such a proponent of community-driven, open-source software development toolkits for the arts.
|
||||
|
||||
<Quote>ComfyUI is the first AI tool I've found with both a low floor and a high ceiling. It's incredibly powerful and flexible, in terms of allowing artists to design their own AI workflows with the latest cutting-edge algorithms. But it also leapfrogs the headaches of coping with quirky GitHub repos and obsolete Colab notebooks.</Quote>
|
||||
|
||||
### What were students stuck on before?
|
||||
|
||||
Students often found themselves caught between two worlds. On one side were commercial AI tools that produced impressive results but offered limited opportunities for customization. On the other side were research projects published by universities and laboratories, where the software was often difficult to install, poorly documented, or already out of date.
|
||||
|
||||
ComfyUI bridges that gap. It gives students access to state-of-the-art algorithms through an environment they can understand, modify, and extend. Instead of adapting their ideas to fit a tool's built-in workflow, they can build workflows that reflect their own interests and questions.
|
||||
|
||||
<Quote>My students are explorers. They're artists who can write code and want to build systems that haven't existed before.</Quote>
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-3">
|
||||
|
||||
### The first exercise: a p5.js sketch driving image synthesis, inside ComfyUI
|
||||
|
||||
In one of Levin's introductory exercises — students' first exposure to the ComfyUI environment — they write a simple p5.js sketch directly inside ComfyUI, then use the shapes they draw, plus a text prompt, to guide a Stable Diffusion image synthesis. They document the pairs of images it produces: their JavaScript canvas drawing on the left, and the AI synthesis on the right. Having already spent a few weeks fighting to get nuance out of p5.js, they're tickled to get these results from simple shapes, and they learn a lot about how Stable Diffusion works.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/golan-levin/p5-landscape.png" alt="p5.js ellipses guiding a Stable Diffusion synthesis" caption={`Some wide ellipses drawn in p5.js (left) guiding a Stable Diffusion synthesis with the prompt "rolling hills, foggy day" (right).`} />
|
||||
|
||||
It runs on a node-based canvas that art students pick up quickly, because it works like tools they already know.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/golan-levin/p5-workflow.png" alt="Template ComfyUI workflow using the ComfyUI-p5js-node" caption="The template ComfyUI workflow students receive. It uses the custom ComfyUI-p5js-node by Ben Fox. From Levin's 60-212 course repo." />
|
||||
|
||||
*Try it yourself: [json file](https://media.comfy.org/website/customers/golan-levin/p5-in-comfy.json) (Comfy Local only)*
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-4">
|
||||
|
||||
### Many artists start off by using ComfyUI for generative AI. You use it differently.
|
||||
|
||||
Maybe so. I'm interested in AI as a framework for expanded perception, so a lot of how I've used machine learning and computer vision over the past 25 years has been for image analysis, rather than image synthesis. Essentially, I use computer vision to understand video and images, and then use the information I extract to create new kinds of interactive experiences. In the classroom, I use ComfyUI to help teach students how to "see like a machine." So I have students use ComfyUI as a framework for analyzing images, not just generating them. For example, I ask them to take an input image and then use AI to compute new ones from it, such as a semantic segmentation ("which pixels belong to the elephant?") and a monocular depth estimate ("how far away is each pixel?"). Then the students build an interactive piece that interprets the original image, but using five channels of information instead of three: the usual red, green, and blue, plus depth, plus segmentation. In my demo project, the segmentation colors the elephant pink, and the background pixels change size based on how far away the AI thinks they are.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/golan-levin/depth-segmentation.png" alt="Semantic segmentation and monocular depth analysis in ComfyUI" caption={`An input image analyzed inside ComfyUI: semantic segmentation and monocular depth, feeding a five-channel "Custom Pixel" exercise. From Levin's 60-212 course repo.`} />
|
||||
|
||||
*Try it yourself: [demo project](https://editor.p5js.org/golan/sketches/-_cFmLtoP) · [lesson plan & workflow](https://github.com/golanlevin/60-212/tree/main/lectures/comfy/image_analysis#3-segment-the-image-with-ai)*
|
||||
|
||||
*Workflow files: download the [.json](https://media.comfy.org/website/customers/golan-levin/image-analysis-workflow.json), or the [.png with the workflow embedded in its metadata](https://media.comfy.org/website/customers/golan-levin/image-analysis-workflow.png) (drag it into ComfyUI to load the graph).*
|
||||
|
||||
<Quote>I want students to understand that AI is not only a tool for generating images. It's also a tool for perception, measurement, and analysis.</Quote>
|
||||
|
||||
The computer vision tools built for this are usually aimed at developers and enterprises. They assume an engineering workflow. I wanted my art students to get to segmentation, depth, and tracking inside an environment they already think in, without standing up a production pipeline first.
|
||||
|
||||
### What changed once ComfyUI was in the workflow?
|
||||
|
||||
Two things. First, it runs on a node-based canvas that many art students already understand from environments like TouchDesigner, Max/MSP, and Grasshopper — except it runs in a browser and it's for AI. As a result, students can focus on the ideas behind machine learning workflows instead of first learning an entirely new interaction paradigm. Second, it collapses the distance between a research lab and a classroom.
|
||||
|
||||
<Quote>There's a fast pipeline from the lab to your classroom. It's become commonplace for enthusiasts to convert AI research code into Comfy nodes, often within days of their release.</Quote>
|
||||
|
||||
One of the most remarkable things about the ComfyUI ecosystem is how quickly new research becomes accessible. A computer-vision paper might appear at CVPR or ICCV, and within days someone in the community has wrapped it as a reusable ComfyUI node. For educators, that dramatically shortens the distance between a research laboratory and a classroom. Instead of spending weeks reconstructing an experimental software environment, students can begin exploring the underlying ideas almost immediately.
|
||||
|
||||
The cloud matters for accessibility and equity, too. Most of my students don't have big GPU workstations, and I don't want their access to advanced tools to depend on the caliber of their personal hardware. Cloud platforms make it possible for everyone in a class to work in the same environment, with the same models, regardless of what laptop they happen to own.
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-5">
|
||||
|
||||
### In your advanced Experimental Capture studio, you've turned ComfyUI into a computer-vision lab.
|
||||
|
||||
The goal of this course is to use technologies to help us see the world in new ways: the very fast, the very slow, the very small, the very large, and in spectra beyond human perception, like IR and UV. It's about cultivating the students' curiosity. But the limitation in this studio is hardware. We have one camera that can shoot 100,000 frames per second, one high-resolution thermal camera, and access to one electron microscope — but we've got 20 students. We can't always queue them all up for one exotic camera; it's a bottleneck.
|
||||
|
||||
<Quote>I need to give them tools they can use to see the world in new ways, that they can all run on their own hardware.</Quote>
|
||||
|
||||
ComfyUI allows students to use their own phones to ask questions they couldn't before. So they duct-tape their phone camera to a window, record the world going by, and then track things with the LocateAnything and SAM3 ComfyUI nodes, producing data files that distill what the camera saw. ComfyUI becomes a laboratory for computational observation, allowing students to ask questions of images and videos that would otherwise be difficult to formulate.
|
||||
|
||||
### You also wrap niche research libraries into ComfyUI nodes yourself.
|
||||
|
||||
One of the remarkable things about the ComfyUI ecosystem is the community that forms around it. There's a hero of mine on GitHub, Kijai, who keeps taking libraries from computer vision labs and turning them into ComfyUI nodes. He's made hundreds, probably doing more than anyone to turn lab-grade models into tools anyone can use. My students and I are starting to do this too. Niche is the right word. Right now I have my eye on a zoology lab that released a good library for tracking insect legs. The people who made it probably don't even know what ComfyUI is. But I want that algorithm for my students, and there's gotta be someone else out there who would love it too.
|
||||
|
||||
### What's the bigger pattern you see in your students?
|
||||
|
||||
My students are explorers. They see a new tool and immediately start wondering what else it could be connected to. They explore: I should be able to combine this thing with that other thing. That's the whole reason to give them a system they can build on, instead of a tool that tells them what they're allowed to do.
|
||||
|
||||
<Quote>We're educating students who want to invent new forms and experiences, not just reproduce existing ones.</Quote>
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-6" title="At a glance">
|
||||
|
||||
<AtAGlance rows={[
|
||||
{ label: "Courses", value: "Intermediate Studio: Creative Coding (60-212); Experimental Capture (co-taught with Nica Ross)" },
|
||||
{ label: "Level", value: "Undergraduate (sophomore studio + advanced studio, ~20 students)" },
|
||||
{ label: "Setup", value: "Cloud-hosted ComfyUI; runs on students' own laptops" },
|
||||
{ label: "Core techniques", value: "p5.js-driven synthesis; semantic segmentation; monocular depth; LocateAnything + SAM3 tracking" },
|
||||
{ label: "Distinctive angle", value: "ComfyUI as computer-vision lab, not just a generator" }
|
||||
]} />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-7" title="Student work">
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/golan-levin/student-tippi.png" alt="Student work by Tippi Li" caption={`"nuclear explosion" by Tippi Li`} />
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/golan-levin/student-xiao.png" alt="Student work by Xiao Yuan" caption={`"Chinese painting, plants, ink, transparent" by Xiao Yuan`} />
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/golan-levin/student-aarnav.png" alt="Student work by Aarnav Patel" caption={`"NASA space image of a new cosmos detected" by Aarnav Patel`} />
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/golan-levin/student-jeffrey.png" alt="Student work by Jeffrey Wang" caption={`"Dream Scene Painting" by Jeffrey Wang`} />
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/golan-levin/student-kai.gif" alt="Student work by Kai Okorodudu" caption={`"Electric hand" by Kai Okorodudu`} />
|
||||
|
||||
</Section>
|
||||
|
||||
<AuthorBio people={[{ name: "Golan Levin", photo: "https://media.comfy.org/website/customers/golan-levin/author-golan.png" }]}>Golan Levin is a Professor of Computational Art at Carnegie Mellon University and co-author, with Tega Brain, of "Code as Creative Medium." This fall he is teaching two CMU courses with ComfyUI: "Intermediate Studio: Creative Coding" (60-212), built around p5.js, and "Experimental Capture," a studio in computational and expanded photography he co-teaches with Nica Ross. Levin is also widely known for interactive art installations driven by real-time machine vision, such as his [Augmented Hand Series](https://flong.com/archive/projects/augmented-hand-series/index.html) (2014), created with Kyle McDonald and Christine Sugrue.</AuthorBio>
|
||||
|
||||
<EducationCta />
|
||||
149
apps/website/src/content/customers/en/ina-conradi.mdx
Normal file
149
apps/website/src/content/customers/en/ina-conradi.mdx
Normal file
@@ -0,0 +1,149 @@
|
||||
---
|
||||
title: "From Node Graph to Building Façade: how Ina Conradi's NTU students compose architectural-scale public art with ComfyUI"
|
||||
category: "CREATIVE CAMPUS SHOWCASE"
|
||||
description: "At NTU in Singapore, Ina Conradi's students compose 90-second films for building-sized LED walls that prompt boxes cannot render but ComfyUI can, work that travels from campus to Hangzhou's West Lake Media Façade and a million viewers a day."
|
||||
cover: "https://media.comfy.org/website/customers/ina-conradi/cover.png"
|
||||
order: 6
|
||||
sections:
|
||||
- id: topic-1
|
||||
label: "INTRO"
|
||||
- id: topic-2
|
||||
label: "THE CANVAS"
|
||||
- id: topic-3
|
||||
label: "WHY COMFYUI"
|
||||
- id: topic-4
|
||||
label: "THE 2026 BRIEF"
|
||||
- id: topic-5
|
||||
label: "STUDENT WORK"
|
||||
- id: topic-6
|
||||
label: "PUBLIC SCREENS"
|
||||
- id: topic-7
|
||||
label: "AT A GLANCE"
|
||||
---
|
||||
|
||||
<Section id="topic-1">
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/ina-conradi/fig1-quantum-logos.jpg" alt="Quantum Logos (Vision Serpent) on the Media Art Nexus LED screen" caption="Quantum Logos (Vision Serpent), Mark Chavez and Ina Conradi. Experimental animation, Media Art Nexus LED screen (15 m × 2 m), Singapore. Photo: Quek Jia Liang." />
|
||||
|
||||
### Building an AI art pipeline from studio to screen
|
||||
|
||||
Ina Conradi has written and taught NTU's two AI courses since 2022: DM2012, Explorations in AI-Generated Art (undergraduate), and AP7055, Art in the Age of the Creative Machine (postgraduate). Each runs about 30 students a semester. Working alongside her on the production pipeline is Mark Chavez, an animation veteran (DreamWorks, Rhythm & Hues) and early ComfyUI adopter. Together they co-curate the platform those courses build for: a 15-metre by 2-metre LED wall installed at NTU's North Spine in 2016 as Media Art Nexus, now run by NTU Museum as NTU Index and still taking new work each semester.
|
||||
|
||||
Work from the wall has travelled to giant public screens in Singapore (Ten Square), Hangzhou, and Chongqing, and into collaborations with Bauhaus University, the University of the Arts Berlin, and the Elbphilharmonie in Hamburg.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/ina-conradi/fig2-nature-sanctuary.jpg" alt="Nature Sanctuary 3000 on the West Lake Media Façade" caption="Nature Sanctuary 3000, Sowmya Sreeshna. Experimental animation, West Lake Media Façade (170 m × 18 m), Hangzhou, China. Photo: Limpid Art." />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-2">
|
||||
|
||||
### Ina, your students don't make films for laptops. Why screens the size of buildings?
|
||||
|
||||
Because the format teaches. A 90-second film at 6K across, in an 8:1 panorama, cannot be a lucky prompt. It has to be composed. And the screens are real: the strongest student work plays on NTU Index, our 15-metre by 2-metre wall on campus, and travels to urban façades in China and Europe through the City Digital Skin Art Festival (CDSA). When a student knows a million people a day might walk past their film in Hangzhou, the conversation about craft changes.
|
||||
|
||||
### Mark, describe the canvas.
|
||||
|
||||
Basically, we do compositions for really large media LED screens in Singapore and China. We have a screen that's eight by one in Singapore. It's 5,888 by 768 pixels.Students create images in the class, usually about 6K resolution across, a long landscape panorama. The output is 90-second short films. Two minutes, 90 seconds. I'm not going to change. I love that format because it's manageable within the class.
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-3">
|
||||
|
||||
### That format breaks most AI tools. What happened?
|
||||
|
||||
Runway is one of the tools we use, on an educational plan that has worked well for the school. The constraint we hit is format: Runway works in 16:9, and our 6K panoramas fall outside that. Last semester Midjourney gave us trouble at our resolution, and the upscale was difficult. So we're expanding the palette and bringing in ComfyUI alongside what we already run.
|
||||
|
||||
<Quote>ComfyUI gave the cleanest results. Upscaling to 8K at a 1-by-8 panorama after composition is genuinely hard, and ComfyUI is the only pipeline that lets students compose image, motion, and upscale models together.</Quote>
|
||||
|
||||
### What about the budget side?
|
||||
|
||||
Budget will keep being an issue. The school supports us well, but new tools arrive every semester and students want to try them and build their own pipelines. Monthly per-seat licenses don't fit how a semester runs. Running ComfyUI locally is hard for students: most laptops don't have a GPU with enough VRAM, and getting it working takes real trial and error. Many would rather work from home, but the hardware blocks them, so they come into the lab. Others used Comfy Cloud. It charges a subscription, but it still cost significantly less than the prepaid tools, and the results were better. Either way they're chasing the same thing: a pipeline they can keep working on, wherever they are.
|
||||
|
||||
### Ina, you insist these courses are not about tools. What are they about?
|
||||
|
||||
My class isn’t about teaching a single tool. It is the responsive system students interact with across platforms, directing, critiquing, and shaping outputs through ongoing dialogue. ComfyUI fits this: a node graph is an argument you can read, question, and rebuild. A prompt box is not. Singaporean students become technically fluent very fast. What they need from arts education is the language to question what they're making, not just the skill to make it.
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-4">
|
||||
|
||||
### Ina, the 2026 brief sends students to the ocean. What's the assignment?
|
||||
|
||||
The project is The Liquid Commons: Bringing Ocean Science into Global Media Architecture, developed in dialogue with OceanX, the organization behind the OceanXplorer research vessel, and the CDSA 2026 festival theme. The brief is strict: do not illustrate the science, translate it. The 2026 cohort is the first to build these films in ComfyUI with Topaz upscaling, working towards two real deadlines at once. Their pieces are in consideration for the OceanX Summit in Singapore this October, and jury-selected works will screen during the City Digital Skin Art Festival on Hangzhou's West Lake Media Façade: 170 metres by 18 metres, around a million viewers a day.
|
||||
|
||||
<Quote>The delivery spec tells you why the tooling matters: final exports at 5,888 × 768 px, 8K where required. That's the brief no prompt box can fill.</Quote>
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-5">
|
||||
|
||||
### Mark, what does the student work look like?
|
||||
|
||||
About eight students have built their films through Comfy so far, and they're all pretty cool. They're surprising and insightful, because they're not limited by game-engine graphics. One student was the standout: he tried every model in Comfy and pushed the furthest.
|
||||
|
||||
Three projects from the 2026 cohort show the range.
|
||||
|
||||
**The Tao of Water** (Wang Zilin, AP7055) reads the ocean through the Tao Te Ching, a three-part arc from water to marine plant to void and back to origin. The pipeline moves from Pinterest research boards through Midjourney into ComfyUI, where Nano Banana extends single frames into seamless panoramas and Kling 3.0 animates first-frame-to-last-frame motion at full 5,888-pixel width, before a Premiere edit.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/ina-conradi/fig3-tao-of-water.jpg" alt="The Tao of Water on the NTU Index screen" caption="The Tao of Water, Wang Zilin. Experimental animation, NTU Index screen (15 m × 2 m), Singapore. Photo: Quek Jia Liang." />
|
||||
|
||||
**microscophony** (Jiin Ko, AP7055) fuses *microscopic* and *micropolyphony*, Ligeti's term for dense webs of voices that blur into a single cloud of sound. The source is based on OceanX microscope footage of deep-sea microbes, translated into the visual logic of graphic notation (Ligeti, Xenakis, Cardew) so the panorama becomes a listening score. Images ran through Midjourney and Nano Banana, video through ComfyUI with Vidu Q2, sound design in Ableton Live, with distinct sonic textures mapped to distinct visual forms.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/ina-conradi/fig4-microscophony.jpg" alt="microscophony on the NTU Index screen" caption="microscophony, Jiin Ko. Experimental animation, NTU Index screen (15 m × 2 m), Singapore. Photo: Quek Jia Liang." />
|
||||
|
||||
**GO! PLASTIC** (Jianwei Hoe, DM2012) is an ocean-plastics piece whose production log reads like studio paperwork, not prompt history. It opens with a one-line art direction (every project states its idea in a single line, with embedded irony, before a frame is generated), then walks through model selection, a platform-versus-local cost comparison (cost per clip and per scene on an RTX 5090 against a cloud B200, render times included), and a shot-by-shot sheet pairing every source image with its full prompt and settings.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/ina-conradi/fig5-go-plastic.jpg" alt="GO! PLASTIC on the NTU Index screen" caption="GO! PLASTIC, Hoe Jianwei. Experimental animation, NTU Index screen (15 m × 2 m), Singapore. Photo: Quek Jia Liang." />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-6">
|
||||
|
||||
### Ina, where does the work go after the classroom?
|
||||
|
||||
Onto public screens, and into juried international competition. The City Digital Skin Art Festival was established in 2023, initiated by the China Academy of Art's School of Sculpture and Public Art and co-curated with Public Art Lab Berlin, MEET Digital Culture Center Milan, and NTU ADM, with a network of more than 29 art academies across China and Europe.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/ina-conradi/fig6-cdsa-awards.jpg" alt="CDSA Festival award winners on the West Lake Media Façade" caption="CDSA Festival award winners, curators, and organizers. West Lake Media Façade (170 m × 18 m), Hangzhou, China. Photo: Limpid Art. Asia's largest high-definition outdoor screen" />
|
||||
|
||||
The 2024 edition ran across 11 LED screens in 9 cities in 5 countries and reached over 100 million views. The 2025–2026 edition, themed Memory Coexistence, drew over 200 international submissions, with the top 40 selected by a 16-member jury. I curate the Singapore programme across NTU Index and the Ten Square landmark façade. A student composing at 6K in our classroom is composing for that circuit.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/ina-conradi/fig7-crispr.jpg" alt="Crispr on the Ten Square Landmark Façade" caption="Crispr, Lee Chaewon. Experimental animation, Ten Square Landmark Façade (21.2 m × 14.4 m), Singapore. Photo: Quek Jia Liang." />
|
||||
|
||||
NTU ADM students have already won at this level. At CDSA 2025, the majority of the top awards went to students from these two courses: Gold (Sun Yutong, *Echoes of Her*), Silver (Tan Yu Yan Cheerie, *Eternal Flux*), Bronze (Shah Pranjal Kirti, *Mumbai Miniatures*), Business (Ong Sze Ching, *Nuwa*), and Creative (Leah Chakola, *Caravan of Memory*). The courses have also taken NTU to Ars Electronica in Linz as the only Singapore campus partner since 2023, first with *Butterfly's Dreams* (2023, "Who Owns the Truth?") and then in 2025 with *Beyond the Screen*, a joint exhibition with the China Academy of Art and Bauhaus-Universität Weimar.
|
||||
|
||||
### Mark, you spent a decade at DreamWorks. Why does this tool fit art students?
|
||||
|
||||
I come from visual effects. I was at DreamWorks about ten years, then Rhythm & Hues, then the game industry and big interactive installations. I'm not a programmer, so I love ComfyUI.
|
||||
|
||||
<Quote>Everybody I know who does graphics now is using this, because it's so adaptable. Sometimes we use Comfy as just a back end. That's what everybody's doing.</Quote>
|
||||
|
||||
We got this large 15-metre by 2-metre screen in an art installation at the university, and it let us explore media and different techniques. We found students weren't technical enough to handle TouchDesigner, so they just started making movies. Then I started playing with AI, and now everything's AI. What I'd love next is templates custom-made for these screens.
|
||||
|
||||
Take *Echoes, Whispers and Memories*, the piece Ina and I made. We don't use Comfy to spit out finished illustrations. We build workflows that keep recomposing the image, breaking it apart and putting it back together so it evolves on screen, which is the whole point: entropy, memory, things falling apart and reforming. Then we push those outputs into real-time and projection systems for big rooms, places like Ars Electronica's Deep Space 8K and MEET in Milan.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/ina-conradi/fig8-echoes.jpg" alt="Echoes, Whispers and Memories at Ars Electronica Deep Space 8K" caption="Echoes, Whispers and Memories, Mark Chavez and Ina Conradi. AI-generated immersive installation using ComfyUI, Deep Space 8K, Ars Electronica, Linz, Austria. Photo: Wolfgang Simlinger." />
|
||||
|
||||
### The signal from the industry
|
||||
|
||||
<Quote>I hear from my students looking for internships or jobs that the first question over there is, "Do you know Comfy?" Because they want to hire kids who know the pipeline.</Quote>
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-7" title="At a glance">
|
||||
|
||||
<AtAGlance rows={[
|
||||
{ label: "Institution", value: "Nanyang Technological University, School of Art, Design and Media (Singapore)" },
|
||||
{ label: "Courses", value: "DM2012: Explorations in AI-Generated Art (UG) and AP7055: Art in the Age of the Creative Machine (PG), written and taught by Ina Conradi since 2022; ~30 students/semester" },
|
||||
{ label: "The canvas", value: "6K-wide, 8:1 LED walls in Singapore and China; NTU Index wall on campus (15 m × 2 m, 5,888 × 768 px)" },
|
||||
{ label: "Core technique", value: "ComfyUI compositions with Topaz upscaling for ultra-wide panoramic output; production logs with per-clip cost and prompt sheets" },
|
||||
{ label: "Why Comfy won", value: "Hosted tools locked to 16:9; upscaling to 8K at a 1-by-8 panorama after composition needed a multi-model pipeline; per-seat monthly renewals didn't fit the semester" }
|
||||
]} />
|
||||
|
||||
</Section>
|
||||
|
||||
<AuthorBio label="About the authors" people={[
|
||||
{ name: "Ina Conradi", photo: "https://media.comfy.org/website/customers/ina-conradi/author-ina.jpg", bio: `Ina Conradi is an artist and curator based between Singapore and Los Angeles. She is founding faculty at NTU's School of Art, Design and Media (est. 2005), where she has written and taught the school's AI courses since 2022. Her film Moirai: Thread of Life won Best in Show at the SIGGRAPH Asia Computer Animation Festival 2023, a first for Singapore.` },
|
||||
{ name: "Mark Chavez", photo: "https://media.comfy.org/website/customers/ina-conradi/author-mark.jpg", bio: `Mark Chavez is an animator, director, and founding faculty at NTU's School of Art, Design and Media in Singapore. After a decade at DreamWorks Animation and visual effects work at the original Rhythm & Hues Studios, he established NTU's Digital Animation area (2005) and an animation research think-tank funded by Singapore's National Research Foundation and the Media Development Authority.` }
|
||||
]} />
|
||||
|
||||
<EducationCta />
|
||||
138
apps/website/src/content/customers/en/kathy-smith.mdx
Normal file
138
apps/website/src/content/customers/en/kathy-smith.mdx
Normal file
@@ -0,0 +1,138 @@
|
||||
---
|
||||
title: "Built for AI: Prof. Kathy Smith on USC's Expanded Animation program and ComfyUI"
|
||||
category: "CREATIVE CAMPUS SHOWCASE"
|
||||
description: "Inside the experimental USC MFA that put AI into animation pedagogy from day one, and the student pipelines it produced."
|
||||
cover: "https://media.comfy.org/website/customers/kathy-smith/cover.png"
|
||||
order: 8
|
||||
sections:
|
||||
- id: topic-1
|
||||
label: "THE PROGRAM"
|
||||
- id: topic-2
|
||||
label: "TEACHING WITH AI"
|
||||
- id: topic-3
|
||||
label: "WHY COMFYUI"
|
||||
- id: topic-4
|
||||
label: "STUDENT WORK"
|
||||
- id: topic-5
|
||||
label: "AT A GLANCE"
|
||||
- id: topic-6
|
||||
label: "WHAT'S NEXT"
|
||||
---
|
||||
|
||||
<Section id="topic-1">
|
||||
|
||||
### You built the Expanded Animation program in 2022 specifically to put AI into the curriculum from day one. What did you see that other programs missed?
|
||||
|
||||
We created Expanded Animation: Research and Practice specifically to focus on creative process and AI as part of how animators learn to make work. The thesis at the start was that AI was going to reshape animation as a medium, and the question was not whether to teach it but how to embed it in the curriculum so students learn it as part of their creative process rather than as a separate technical specialty.
|
||||
|
||||
USC's School of Cinematic Arts already had decades of cinematic storytelling tradition. What we did with XA was put AI inside that tradition. The conceptual thinking, the storytelling, the cinematic history come first. AI is one of the many tools available to them, sitting alongside hand-drawing, paint, 3D, and live-action footage. Students do not learn AI in one course and animation in another. They learn both side by side.
|
||||
|
||||
The students who arrive at the program are usually self-selected for it. They show up technically fluent, with their own GPU-equipped laptops. What we offer them is the storytelling, the cinematic history, and the conceptual frame. They bring the technical nimbleness.
|
||||
|
||||
<Quote>They are way ahead of the curve. They are ahead of the faculty in the way they work, technically, but not so much artistically. That is what we are there to deliver.</Quote>
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/kathy-smith/usc-campus.png" alt="USC School of Cinematic Arts" caption="USC School of Cinematic Arts. Source: USC Today" />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-2">
|
||||
|
||||
### How do you actually structure an AI assignment? Walk us through one.
|
||||
|
||||
In my Animation, Dreams, and Consciousness class, I have the students document their dreams and then use the dream as the source. Some of them draw, some of them write. The dream becomes the prompt, and they generate the image and emotion of the dream. I love when you get six fingers and weird stuff happening in the algorithms. Our human perception in dreams is often doing the same thing. Therefore, AI is evolving and dreaming with us.
|
||||
|
||||
That structure is deliberate. The students are not asking the model to produce work for them. They are using it as a layer of their process, alongside hand-drawing and painting and 3D rendering and live-action footage. The work that comes out is theirs because the creative decisions are theirs. The tool just gives them new ways to reach what they were trying to make.
|
||||
|
||||
There is a fear factor around AI, and I understand it. There has been a lot of scraping of artists' work, and that conversation is real and is going to take time to resolve. But I have been working with AI conceptually since 1998, and the way I describe the data sets to my students is that they are a repository of all of our creation. It is like the collective unconscious of the human mind. Artists have always drawn from everything around them.
|
||||
|
||||
<Quote>What really matters is what the artist does with it, *intentionality*.</Quote>
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-3">
|
||||
|
||||
### Why does ComfyUI specifically fit the way your students work?
|
||||
|
||||
It is the node-based system. Those who have done Houdini feel very at home in Comfy. You can work with the prompts, but it is very visual. That is what they are used to. They are not asking a black box for an output. They are building a workflow.
|
||||
|
||||
And it stays in its lane. The students are not using Comfy to make AI art. They are using Comfy as one node graph alongside Blender, hand-drawn frames, paint, and live-action footage. The reason it fits is that it does not try to be the whole pipeline. It is one stage of a creative practice that still has cinema at its core.
|
||||
|
||||
What also matters is that Comfy is open and inspectable. The students can see what the model is doing at each step, fork a workflow, swap a sampler, drop in a custom node, and share what they built with the next cohort. That is closer to how an animation studio tradition has always behaved, with techniques passed along and improved rather than hidden behind a paywall.
|
||||
|
||||
They also work across whatever hardware they have: Comfy Cloud at home and when they are mobile, the portable version on their personal laptops, and the research computer in my office for the high-end runs. Animation students do not sit in one cubicle for a thesis project. They work everywhere.
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-4">
|
||||
|
||||
### Tell us about the work coming out of the program.
|
||||
|
||||
The pattern shows up across the cohort: the AI is in service of the cinematic story, not in place of it. Three students walked us through how Comfy actually sits inside their pipelines.
|
||||
|
||||
#### Sijia Zheng — Ori & Kiddo
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/kathy-smith/ori-kiddo.png" alt="Sijia Zheng, Ori & Kiddo" />
|
||||
|
||||
**What Comfy enabled:** an oil-paint, brush-stroke dream look that "other AI tools cannot possibly make," held consistent across shots with IP-Adapter style transfer and a custom LoRA.
|
||||
|
||||
*Ori & Kiddo* follows two ghosts who, after the universe dies, search for old human memories, rediscover love, and reverse the universe back into being. Most of the film is hand-drawn 2D. Comfy enters in the dream sequences, where the ghost Kiddo dreams of past lives and the look had to be unlike anything else in the film. Sijia drew stylized reference images first, then used them as the style reference over video clips through an IP-Adapter workflow to produce long, oil-painted, brush-stroke sequences. The same control shows up in shots where Sijia appears on screen: real footage, masked in Comfy to change the haircut and swap the background. For a look that has to stay locked, Sijia trains a LoRA and runs it through Comfy.
|
||||
|
||||
Sijia found Comfy in early 2025 while hunting for a style-transfer tool that Midjourney and DALL-E could not deliver, testing it on a stylized animated-film-look conversion.
|
||||
|
||||
<Quote>It totally broke my mind. Most of the time, I think I'll just stand on other people's shoulders. The workflows are already pretty amazing, and I'll base on the workflows and add something that I want.</Quote>
|
||||
|
||||
Since *Ori & Kiddo*, Sijia has taken the same Comfy-anchored workflow into professional commercial video work, on deadlines as tight as four days.
|
||||
|
||||
#### Ion Yunyang Li — L1LY
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/kathy-smith/l1ly.gif" alt="Ion Yunyang Li, L1LY" />
|
||||
|
||||
**What Comfy enabled:** a repeatable multi-step pipeline that drops the filmmaker into a photorealistic world, because "a sequence of a prompt is not the only thing you need."
|
||||
|
||||
Ion taught himself ComfyUI in early 2025, from tutorials in the generative-AI community, and built his most distinctive Comfy work in a body-and-environment project: start from 3D-model stills, convert them to a pencil-sketch style so the model would not over-study the original 3D aesthetic, generate photorealistic frames from the sketches, build character T-poses, composite himself into the scene, and animate the stills with a video model.
|
||||
|
||||
<Quote>A sequence of a prompt is not the only thing you need. You need many different settings, and it is very hard to redo those settings every time.</Quote>
|
||||
|
||||
What he values as much as the pipeline is where it can run: the same Comfy setup moves across a workstation in his school cubicle, a remote session from his apartment laptop, and fully cloud-based instances, depending on where he is.
|
||||
|
||||
#### Sihan Wu — Scary Coaster
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/kathy-smith/scary-coaster.gif" alt="Sihan Wu, Scary Coaster" />
|
||||
|
||||
**What Comfy enabled:** roughly 100 hand-drawn keyframes carried through a single workflow so a two-to-three-minute film stays visually consistent, on his first-ever AI project.
|
||||
|
||||
*Scary Coaster* (December 2024) was Sihan's first project ever made with AI. Coming from a digital-media and game-development undergrad, Sihan joined Professor Smith's Expanded Animation class and wanted something more controllable than the prompt-only tools on offer. The workflow he built: draw roughly 100 rough keyframes by hand, run them through Comfy to find a stylized Chinese-horror look, pick the favorite, then generate the in-betweens to produce the full sequence.
|
||||
|
||||
<Quote>I want to have a more controllable flow. I don't want to just use prompts and generate random images. I just use one workflow to create the whole two or three minutes, and I can make everything look very consistent.</Quote>
|
||||
|
||||
Sihan is honest that the on-ramp was steep: learning from the official ComfyUI GitHub workflows, combining them, and debugging Python environments along the way. His ask was specific: an official, beginner-to-advanced tutorial series. And his view on where AI should head next was equally specific: aim it at "the very time-consuming but not that creative process, like creating in-betweens," and leave the creative decisions to the artist.
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-5" title="At a glance">
|
||||
|
||||
<AtAGlance rows={[
|
||||
{ label: "Program", value: "Expanded Animation: Research + Practice (XA), USC School of Cinematic Arts" },
|
||||
{ label: "Founded", value: "2022, AI embedded in the MFA curriculum from day one" },
|
||||
{ label: "Setup", value: "Students' own GPU laptops + Comfy Cloud + lab research machine" },
|
||||
{ label: "Core techniques", value: "IP-Adapter style transfer, custom LoRAs, masked compositing, keyframe-to-in-between pipelines" },
|
||||
{ label: "Outcomes", value: "Amazing student works from Sihan, and Ion, Sijia" }
|
||||
]} />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-6">
|
||||
|
||||
### What excites you about where this is going?
|
||||
|
||||
I have a philosophy that everyone is an artist. They just forget that they are an artist. Creativity drives everything, and the tools we are getting now make it possible for more people to find that capacity in themselves. ComfyUI, because it is node-based and visual and open, gives non-programmers a way forward that is honest about how the model works. It does not pretend the AI is doing something magical. It shows the artist what is happening at each step.
|
||||
|
||||
The two basic rights of human life are health and education. The work Comfy is doing on the education side is touching something integral. The students who came through XA are already extending the work in directions the program did not anticipate, and the next generation of educators and students will keep doing the same.
|
||||
|
||||
<Quote>Everyone is an artist. They just forget that they are an artist.</Quote>
|
||||
|
||||
</Section>
|
||||
|
||||
<AuthorBio people={[{ name: "Kathy Smith", photo: "https://media.comfy.org/website/customers/kathy-smith/kathy-smith.jpg", bio: `Kathy Smith is Professor of Cinematic Arts at USC's School of Cinematic Arts and inaugural director (2022-2023) of Expanded Animation: Research + Practice (XA), the experimental MFA program she helped found in 2022 to integrate AI into animation pedagogy from the first day of the degree. To date she is the longest-serving chair of combined USC animation programs and has been exploring concepts of AI in her creative practice since 1998.` }]} />
|
||||
|
||||
<EducationCta />
|
||||
56
apps/website/src/content/customers/en/ual-cci.mdx
Normal file
56
apps/website/src/content/customers/en/ual-cci.mdx
Normal file
@@ -0,0 +1,56 @@
|
||||
---
|
||||
title: "Comfy and UAL's Creative Computing Institute Announce Creative Campus Partnership"
|
||||
category: "CREATIVE CAMPUS PARTNERSHIP"
|
||||
description: "Comfy announces Creative Campus Partnership to support teaching and research across UAL CCI's masters, PhD, and industry programmes"
|
||||
cover: "https://media.comfy.org/website/customers/ual-cci/cover.png"
|
||||
order: 9
|
||||
sections:
|
||||
- id: topic-1
|
||||
label: "INTRO"
|
||||
- id: topic-2
|
||||
label: "WHAT CCI DOES"
|
||||
- id: topic-3
|
||||
label: "THE PARTNERSHIP"
|
||||
- id: topic-4
|
||||
label: "ABOUT CCI"
|
||||
---
|
||||
|
||||
<Section id="topic-1">
|
||||
|
||||
Comfy Org, the team behind ComfyUI, the open-source node-based interface for generative AI, and the Creative Computing Institute (CCI) at University of the Arts London today announced a Creative Campus partnership, making CCI a founding partner of the [Comfy Education Initiative](https://comfy.org/education).
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-2">
|
||||
|
||||
CCI already runs ComfyUI at every level of the institute. On the Applied Machine Learning for Creatives masters course, students build image, video, audio, and text workflows, train their own models, and construct interactive pipelines. PhD researchers use Comfy for fine-tuning, custom datasets, and custom node development. The institute also uses ComfyUI in industry training, where its node-based interface gives non-technical collaborators a way into generative AI that code alone does not.
|
||||
|
||||
<Quote name="Prof Mick Grierson, Research Leader, UAL Creative Computing Institute">ComfyUI has become part of how we teach, research, and work with industry. It is one of the few generative AI environments where the workflows our students build are portable, inspectable, and forkable, and that open-source foundation is exactly what a university should be teaching on.</Quote>
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-3">
|
||||
|
||||
Through the partnership, CCI educators and students gain access to classroom licenses with central billing & administration, educational discounts, early access to upcoming team features, a dedicated educator community with direct support from the Comfy team, and a voice in shaping the future of the education program.
|
||||
|
||||
Creative Campus partnerships are the deepest tier of the program: a direct, ongoing collaboration in which an institution works hand in hand with the Comfy team to roll out ComfyUI across teaching, research, and industry training.
|
||||
|
||||
<Quote name="The Comfy Team">CCI is the model we hope every creative campus follows: ComfyUI in the masters classroom, in PhD research, and in industry collaboration, all at once. As our first Creative Campus Partner, they are helping us design an education program that works the way universities actually work.</Quote>
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/ual-cci/cci-camberwell.jpg" alt="Creative Computing Institute campus at UAL" caption="Creative Computing Institute Campus. Photo: Ana Escobar, courtesy UAL." />
|
||||
|
||||
The institute is leading a major £1.5 million publicly funded research programme developing copyright-compliant audiovisual foundation models for the UK's creative industries. Bringing together expertise in sound, image, and artificial intelligence, the project is building open tools and responsible AI national infrastructure designed to support UK creative production, research, and experimentation across the sector.
|
||||
|
||||
The outputs of the research will explore wider dissemination and adoption through open, node-based tools such as ComfyUI to support experimentation, workflows, and collaboration around emerging multimodal AI systems.
|
||||
|
||||
UAL CCI joins a founding cohort of educators and institutions featured at the launch of the Comfy Education Initiative, alongside researchers such as CCI co-founder Dr. Phoenix Perry, whose Antigravity Machine project Comfy supports as an industry partner.
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-4" title="About the Creative Computing Institute at UAL">
|
||||
|
||||
The Creative Computing Institute at University of the Arts London applies computing to creativity and social impact, operating at the intersection of computational technologies and creative practice, teaching undergraduate, postgraduate, and PhD students alongside research and industry collaboration.
|
||||
|
||||
</Section>
|
||||
|
||||
<EducationCta />
|
||||
103
apps/website/src/content/customers/en/xindi-zhang.mdx
Normal file
103
apps/website/src/content/customers/en/xindi-zhang.mdx
Normal file
@@ -0,0 +1,103 @@
|
||||
---
|
||||
title: "The tool that expands my art: Xindi Zhang's Oscar-shortlisted thesis, built in ComfyUI"
|
||||
category: "CREATIVE CAMPUS SHOWCASE"
|
||||
description: "How a USC Expanded Animation thesis became a Student Academy Award winner, an Oscar shortlist entry, and helped land a job at Amazon — with the artist's own illustrations as the style guide."
|
||||
cover: "https://media.comfy.org/website/customers/xindi-zhang/cover.webp"
|
||||
order: 5
|
||||
sections:
|
||||
- id: topic-1
|
||||
label: "INTRO"
|
||||
- id: topic-2
|
||||
label: "WHY COMFYUI"
|
||||
- id: topic-3
|
||||
label: "THE PIPELINE"
|
||||
- id: topic-4
|
||||
label: "AT A GLANCE"
|
||||
- id: topic-5
|
||||
label: "WHAT'S NEXT"
|
||||
---
|
||||
|
||||
<Section id="topic-1">
|
||||
|
||||
<Embed src="https://player.vimeo.com/video/1131160045" title="The Song of Drifters by Xindi Zhang" />
|
||||
|
||||
*From The Song of Drifters. Film images: Xindi Zhang.*
|
||||
|
||||
### Tell us about The Song of Drifters. What is it about, and where did it start?
|
||||
|
||||
The Song of Drifters is a documentary animation about people caught between leaving and returning, wanderers who drift through unfamiliar cities, holding onto memories of a homeland out of reach and searching for a sense of belonging. The title is a direct translation from an ancient Chinese poem about a mother's love for a child who leaves her hometown. My version takes the opposite point of view, from the child's perspective.
|
||||
|
||||
I built the film in ComfyUI. When I started, I was not trying to show what AI could do. I was trying to prove something almost opposite.
|
||||
|
||||
<Quote>It started as a challenge to the stereotype that AI-generated work is generic and cheap. I wanted to prove that AI could be an amplifier for personal vision, not a replacement for it.</Quote>
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-2">
|
||||
|
||||
### You came to this from illustration, not engineering. How did you end up in ComfyUI?
|
||||
|
||||
I started as an illustrator. I earned my BFA in illustration at the Rhode Island School of Design, then worked as a game concept artist, where I picked up shaders, Unity, and Unreal. That technical side made me a fast learner with new tools. Later I went to USC's School of Cinematic Arts for an MFA in Expanded Animation, where I studied with Professor Kathy Smith.
|
||||
|
||||
By my thesis year I had moved from Stable Diffusion's standard interfaces to ComfyUI, because I think in node-based structures and I wanted to control every step. Most AI tools are one click: you prompt, you click, you get a result. That is not what I wanted.
|
||||
|
||||
<Quote>I want to control the process, and the process is even more important than the result itself. For artists like me, I don't want to automate anything. I want to participate in every single stage of designing the workflow. That's the fun part of it.</Quote>
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-3">
|
||||
|
||||
### Walk us through the pipeline. What were you actually feeding the model?
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/xindi-zhang/balloon-workflow.png" alt="Xindi's ComfyUI workflow for the balloon sequence">Xindi's ComfyUI workflow for the balloon sequence. Source: [xindizhangart.com](https://xindizhangart.com).</Figure>
|
||||
|
||||
My core technique was style transfer in Stable Diffusion 1.5, driven by IP-Adapter and ControlNet. What mattered most was what I fed it: my own work. The base materials were live-action footage I shot on an iPhone 15 Pro and 3D animation I built in Blender. The AI restyled imagery I had already made. It did not invent it.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/xindi-zhang/film-still.jpg" alt="Style-guide still from The Song of Drifters">Style-guide still from The Song of Drifters. Source: [xindizhangart.com](https://xindizhangart.com).</Figure>
|
||||
|
||||
<Quote>Unlike most AI-generated videos, which use other artists' works from the model, I use my own illustrations as the style guide.</Quote>
|
||||
|
||||
<Download href="https://media.comfy.org/website/customers/xindi-zhang/workflows/style-transfer-workflow.json" label="Download Xindi's style transfer workflow (json) on ComfyUI" />
|
||||
|
||||
I also trained custom LoRAs on my own video, footage of the cities I had lived in. Capturing that footage became a vital part of the documentary process. Wandering through the streets where I once lived let me reconnect with those cities. Most of it never appears in the final cut, but it lives in the visuals as training data. The hybrid pipeline made rendering the final look more efficient and saved more time for ideation.
|
||||
|
||||
For the dream sequences I combined animated 3D with AI morphing, moving from abstract to concrete to mimic the feeling of being half awake.
|
||||
|
||||
<Video src="https://media.comfy.org/website/customers/xindi-zhang/bts-clip.mp4" poster="https://media.comfy.org/website/customers/xindi-zhang/bts-poster.jpg" caption="BTS clip, AI morphing. Source: Xindi Zhang." />
|
||||
|
||||
<Download href="https://media.comfy.org/website/customers/xindi-zhang/workflows/morphing-workflow.json" label="Download Xindi's AI morphing workflow (json) on ComfyUI" />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-4" title="At a glance">
|
||||
|
||||
<AtAGlance rows={[
|
||||
{ label: "Program", value: "USC School of Cinematic Arts — MFA Expanded Animation (thesis)" },
|
||||
{ label: "Base materials", value: "iPhone 15 Pro live-action; her own Blender 3D animation" },
|
||||
{ label: "Core technique", value: "Style transfer in SD 1.5 via IP-Adapter + ControlNet, in ComfyUI" },
|
||||
{ label: "Style source", value: "Her own illustrations + custom LoRAs trained on her own city footage" },
|
||||
{ label: "Finishing", value: "Depth, mask, and fade passes in After Effects; heavy compositing" },
|
||||
{ label: "Outcome", value: "Student Academy Awards Golden Award (2025); 98th Academy Awards shortlist; AI Creative role at Amazon AI Studio" }
|
||||
]} />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-5">
|
||||
|
||||
### The film won gold at the Student Academy Awards and was shortlisted for the Oscars. What's next?
|
||||
|
||||
I made the film for creative reasons, not career ones. I honestly did not expect it to connect to a job at all. Then it won the Golden Award at the 2025 Student Academy Awards and was shortlisted for the Oscars, and the calls started.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/xindi-zhang/awards.png" alt="Xindi Zhang at the 2025 Student Academy Awards" caption="Xindi Zhang at the 2025 Student Academy Awards. Source: Oscars Press Office." />
|
||||
|
||||
What people wanted was the combination: someone who understands both traditional craft and AI tools. I now work as an AI Creative at Amazon AI Studio building custom production pipelines. I see that same demand across the industry, with ComfyUI experience starting to show up as a requirement in job postings at major studios and design agencies.
|
||||
|
||||
<Quote>It's not the tool that steals my art. It's the tool that expands my art.</Quote>
|
||||
|
||||
My advice to other students is not really about software. AI is just another tool to convey ideas, but nothing is more important than the story itself. If you use AI, use it on purpose. The more you understand it, the more freedom you have to make work that is genuinely yours.
|
||||
|
||||
</Section>
|
||||
|
||||
<AuthorBio people={[{ name: "Xindi Zhang", photo: "https://media.comfy.org/website/customers/xindi-zhang/profile.jpg", bio: `Xindi Zhang is a Chinese animation director and visual artist (RISD BFA in illustration, 2020; USC MFA in Expanded Animation, 2025). The Song of Drifters won the Golden Award at the 2025 Student Academy Awards and was shortlisted for the 98th Academy Awards. She works as an AI Creative at Amazon AI Studio, has collaborated with Sony Music's immersive studio, and is now on the faculty at the University of South Florida.` }]} />
|
||||
|
||||
<EducationCta />
|
||||
@@ -61,6 +61,11 @@
|
||||
|
||||
@theme {
|
||||
--color-site-dropdown: #332b38;
|
||||
--color-site-bg-soft: color-mix(
|
||||
in srgb,
|
||||
var(--color-primary-comfy-ink) 88%,
|
||||
black 12%
|
||||
);
|
||||
--color-primary-comfy-yellow: #f2ff59;
|
||||
--color-primary-comfy-ink: #211927;
|
||||
--color-primary-comfy-ink-light: #2a2330;
|
||||
@@ -261,6 +266,6 @@ video::-webkit-media-controls-panel {
|
||||
|
||||
:root {
|
||||
--site-bg: var(--color-primary-comfy-ink);
|
||||
--site-bg-soft: color-mix(in srgb, var(--site-bg) 88%, black 12%);
|
||||
--site-bg-soft: var(--color-site-bg-soft);
|
||||
--site-border-subtle: rgb(255 255 255 / 0.1);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { render } from '@testing-library/vue'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -6,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'
|
||||
|
||||
@@ -18,7 +16,7 @@ vi.mock('@/scripts/app', () => ({
|
||||
app: { canvas: { graph: { getNodeById: () => appState.node } } }
|
||||
}))
|
||||
|
||||
const ctxObj: unknown = {
|
||||
const ctx = {
|
||||
measureText: (s: string) => ({ width: s.length * 7 }),
|
||||
setTransform: () => {},
|
||||
clearRect: () => {},
|
||||
@@ -35,28 +33,13 @@ const ctxObj: unknown = {
|
||||
fillStyle: '',
|
||||
strokeStyle: '',
|
||||
lineWidth: 0
|
||||
}
|
||||
const ctx = ctxObj as CanvasRenderingContext2D
|
||||
} 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
|
||||
})
|
||||
const getCtx: unknown = () =>
|
||||
options.context === undefined ? ctx : options.context
|
||||
el.getContext = getCtx 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,
|
||||
@@ -91,7 +74,7 @@ const pe = (
|
||||
clientY: number,
|
||||
over: Partial<PointerEvent> = {}
|
||||
) =>
|
||||
fromPartial<PointerEvent>({
|
||||
({
|
||||
button: 0,
|
||||
clientX,
|
||||
clientY,
|
||||
@@ -100,7 +83,7 @@ const pe = (
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {},
|
||||
...over
|
||||
})
|
||||
}) as unknown as PointerEvent
|
||||
|
||||
const flush = async () => {
|
||||
await Promise.resolve()
|
||||
@@ -113,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,
|
||||
@@ -176,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))
|
||||
@@ -238,131 +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 the fallback palette color', async () => {
|
||||
const fillStyles: string[] = []
|
||||
const fillText = vi.fn()
|
||||
const recordingCtx: unknown = {
|
||||
measureText: (s: string) => ({ width: s.length * 7 }),
|
||||
setTransform: () => {},
|
||||
clearRect: () => {},
|
||||
fillRect: () => {},
|
||||
strokeRect: () => {},
|
||||
fillText,
|
||||
drawImage: () => {},
|
||||
save: () => {},
|
||||
restore: () => {},
|
||||
beginPath: () => {},
|
||||
rect: () => {},
|
||||
clip: () => {},
|
||||
font: '',
|
||||
strokeStyle: '',
|
||||
lineWidth: 0,
|
||||
set fillStyle(value: string) {
|
||||
fillStyles.push(value)
|
||||
},
|
||||
get fillStyle() {
|
||||
return fillStyles.at(-1) ?? ''
|
||||
}
|
||||
}
|
||||
const c = setup([
|
||||
box({
|
||||
x: 10,
|
||||
y: 10,
|
||||
width: 30,
|
||||
height: 30,
|
||||
metadata: {
|
||||
type: 'text',
|
||||
text: 'hello',
|
||||
desc: 'alpha beta\n\ncharlie',
|
||||
palette: []
|
||||
}
|
||||
})
|
||||
])
|
||||
c.canvasEl.value = makeCanvas({
|
||||
context: recordingCtx as CanvasRenderingContext2D
|
||||
})
|
||||
|
||||
c.focused.value = true
|
||||
c.syncState()
|
||||
await flush()
|
||||
|
||||
expect(fillText).toHaveBeenCalled()
|
||||
expect(fillStyles.some((s) => s.includes('#8c8c8c'))).toBe(true)
|
||||
})
|
||||
|
||||
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', () => {
|
||||
@@ -375,13 +199,11 @@ describe('useBoundingBoxes region editing', () => {
|
||||
|
||||
it('deletes the active region on Delete', async () => {
|
||||
const c = setup([box()])
|
||||
c.onCanvasKeyDown(
|
||||
fromPartial<KeyboardEvent>({
|
||||
key: 'Delete',
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {}
|
||||
})
|
||||
)
|
||||
c.onCanvasKeyDown({
|
||||
key: 'Delete',
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {}
|
||||
} as unknown as KeyboardEvent)
|
||||
await flush()
|
||||
expect(c.modelValue.value).toHaveLength(0)
|
||||
})
|
||||
@@ -392,74 +214,12 @@ 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(
|
||||
fromPartial<KeyboardEvent>({
|
||||
key: 'Backspace',
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {}
|
||||
})
|
||||
)
|
||||
await flush()
|
||||
expect(c.modelValue.value).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('ignores unrelated keys and key events while drawing', async () => {
|
||||
const c = setup([box()])
|
||||
c.onCanvasKeyDown(
|
||||
fromPartial<KeyboardEvent>({
|
||||
key: 'Enter',
|
||||
preventDefault: () => {
|
||||
throw new Error('should not prevent')
|
||||
},
|
||||
stopPropagation: () => {}
|
||||
})
|
||||
)
|
||||
c.onPointerDown(pe(80, 80))
|
||||
c.onCanvasKeyDown(
|
||||
fromPartial<KeyboardEvent>({
|
||||
key: 'Delete',
|
||||
preventDefault: () => {
|
||||
throw new Error('should not prevent while drawing')
|
||||
},
|
||||
stopPropagation: () => {}
|
||||
})
|
||||
)
|
||||
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(
|
||||
fromPartial<KeyboardEvent>({
|
||||
key: 'Delete',
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {}
|
||||
})
|
||||
)
|
||||
await flush()
|
||||
|
||||
expect(c.modelValue.value).toHaveLength(1)
|
||||
expect(c.activeRegion.value).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useBoundingBoxes inline editor', () => {
|
||||
it('opens on double click and commits the description', async () => {
|
||||
const c = setup([box()])
|
||||
c.onDoubleClick(pe(30, 30) as MouseEvent)
|
||||
c.onDoubleClick(pe(30, 30) as unknown as MouseEvent)
|
||||
await flush()
|
||||
expect(c.inlineEditor.value).not.toBeNull()
|
||||
|
||||
@@ -472,91 +232,11 @@ describe('useBoundingBoxes inline editor', () => {
|
||||
|
||||
it('closes the inline editor on Escape', async () => {
|
||||
const c = setup([box()])
|
||||
c.onDoubleClick(pe(30, 30) as MouseEvent)
|
||||
c.onDoubleClick(pe(30, 30) as unknown as MouseEvent)
|
||||
await flush()
|
||||
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 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 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 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 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 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 MouseEvent)
|
||||
await flush()
|
||||
|
||||
expect(c.inlineEditor.value).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useBoundingBoxes hover cursor', () => {
|
||||
@@ -567,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,117 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useFocusNode } from '@/composables/canvas/useFocusNode'
|
||||
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
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', createMockLGraphNode(node)]])
|
||||
)
|
||||
|
||||
expect(getNodeByExecutionId).not.toHaveBeenCalled()
|
||||
expect(canvasStore.canvas.setGraph).not.toHaveBeenCalled()
|
||||
expect(canvasStore.canvas.animateToBounds).toHaveBeenCalledWith(bounds)
|
||||
})
|
||||
})
|
||||
@@ -1,116 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
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'
|
||||
})
|
||||
})
|
||||
|
||||
it('recomputes the canvas position when layout settings change', async () => {
|
||||
const sidebarLocation = ref('left')
|
||||
mocks.getSetting.mockImplementation((key: string) =>
|
||||
key === 'Comfy.Sidebar.Location' ? sidebarLocation.value : undefined
|
||||
)
|
||||
|
||||
useAbsolutePosition()
|
||||
expect(mocks.updateCanvasPosition).not.toHaveBeenCalled()
|
||||
|
||||
sidebarLocation.value = 'right'
|
||||
await nextTick()
|
||||
|
||||
expect(mocks.updateCanvasPosition).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -1,87 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const canvasObj: unknown = {
|
||||
canvas: {},
|
||||
ds: {
|
||||
offset: [10, 20],
|
||||
scale: 2
|
||||
}
|
||||
}
|
||||
const canvas = canvasObj 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { installErrorClearingHooks } from '@/composables/graph/useErrorClearingHooks'
|
||||
import { promoteValueWidgetViaSubgraphInput } from '@/core/graph/subgraph/promotionUtils'
|
||||
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { INodeInputSlot } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
@@ -21,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'
|
||||
@@ -132,46 +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'
|
||||
)
|
||||
|
||||
const nullSlot: unknown = null
|
||||
node.onConnectionsChange!(
|
||||
NodeSlotType.INPUT,
|
||||
12,
|
||||
true,
|
||||
null,
|
||||
nullSlot as INodeInputSlot
|
||||
)
|
||||
|
||||
expect(store.lastNodeErrors).not.toBeNull()
|
||||
})
|
||||
|
||||
it('clears errors for pure input slots without widget property', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
@@ -271,38 +229,9 @@ describe('Widget change error clearing via onWidgetChanged', () => {
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
const store = useExecutionErrorStore()
|
||||
const noGraph: unknown = undefined
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(noGraph as LGraph)
|
||||
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('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)
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(
|
||||
fromAny<LGraph, unknown>(undefined)
|
||||
)
|
||||
store.lastNodeErrors = {
|
||||
[String(node.id)]: {
|
||||
errors: [
|
||||
@@ -462,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)
|
||||
@@ -681,7 +492,10 @@ describe('onNodeRemoved clears missing asset errors by execution ID', () => {
|
||||
|
||||
const modelStore = useMissingModelStore()
|
||||
modelStore.setMissingModels([
|
||||
fromPartial<MissingModelCandidate>({
|
||||
fromAny<
|
||||
Parameters<typeof modelStore.setMissingModels>[0][number],
|
||||
unknown
|
||||
>({
|
||||
nodeId: String(node.id),
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
@@ -718,7 +532,10 @@ describe('onNodeRemoved clears missing asset errors by execution ID', () => {
|
||||
const interiorExecId = `${subgraphNode.id}:${interiorNode.id}`
|
||||
const modelStore = useMissingModelStore()
|
||||
modelStore.setMissingModels([
|
||||
fromPartial<MissingModelCandidate>({
|
||||
fromAny<
|
||||
Parameters<typeof modelStore.setMissingModels>[0][number],
|
||||
unknown
|
||||
>({
|
||||
nodeId: interiorExecId,
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
@@ -749,7 +566,10 @@ describe('onNodeRemoved clears missing asset errors by execution ID', () => {
|
||||
|
||||
const mediaStore = useMissingMediaStore()
|
||||
mediaStore.setMissingMedia([
|
||||
fromPartial<MissingMediaCandidate>({
|
||||
fromAny<
|
||||
Parameters<typeof mediaStore.setMissingMedia>[0][number],
|
||||
unknown
|
||||
>({
|
||||
nodeId: interiorExecId,
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
@@ -914,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', () => {
|
||||
@@ -1151,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', () => {
|
||||
@@ -1310,168 +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')
|
||||
const noType: unknown = undefined
|
||||
node.type = noType as LGraphNode['type']
|
||||
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()
|
||||
@@ -1480,7 +1012,7 @@ describe('scan skips interior of bypassed subgraph containers', () => {
|
||||
|
||||
const modelStore = useMissingModelStore()
|
||||
modelStore.setMissingModels([
|
||||
fromPartial<MissingModelCandidate>({
|
||||
fromAny<MissingModelCandidate, unknown>({
|
||||
nodeId: '65',
|
||||
sourceExecutionId: createNodeExecutionId([65, 77, 1]),
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
@@ -1507,7 +1039,7 @@ describe('scan skips interior of bypassed subgraph containers', () => {
|
||||
const { rootGraph, outerSubgraph, innerSubgraphNode, outerSubgraphNode } =
|
||||
createNestedSubgraphRuntime()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(rootGraph)
|
||||
const hostCandidate = fromPartial<MissingModelCandidate>({
|
||||
const hostCandidate = fromAny<MissingModelCandidate, unknown>({
|
||||
nodeId: '65',
|
||||
sourceExecutionId: createNodeExecutionId([65, 77, 1]),
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LGraphGroup } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
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
|
||||
}))
|
||||
|
||||
describe('useFrameNodes', () => {
|
||||
let resizeToSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
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()
|
||||
}
|
||||
// Real LGraphGroup constructor; only resizeTo's own geometry math is
|
||||
// out of scope here (see LGraphGroup's own test coverage for that).
|
||||
resizeToSpy = vi
|
||||
.spyOn(LGraphGroup.prototype, 'resizeTo')
|
||||
.mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resizeToSpy.mockRestore()
|
||||
})
|
||||
|
||||
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(resizeToSpy).not.toHaveBeenCalled()
|
||||
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 = mockApp.canvas.graph.add.mock.calls[0]?.[0] as LGraphGroup
|
||||
expect(group).toBeInstanceOf(LGraphGroup)
|
||||
expect(resizeToSpy).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 { fromPartial } 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 {
|
||||
@@ -18,12 +13,9 @@ import {
|
||||
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
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(() => {
|
||||
@@ -271,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', () => {
|
||||
@@ -784,539 +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()
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
|
||||
expect(() =>
|
||||
graph.trigger('node:property:changed', {
|
||||
nodeId: 'missing',
|
||||
property: 'title',
|
||||
newValue: 'ignored'
|
||||
})
|
||||
).not.toThrow()
|
||||
expect(vueNodeData.has(toNodeId('missing'))).toBe(false)
|
||||
expect(vueNodeData.size).toBe(0)
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
const linkId: unknown = 123
|
||||
input.link = linkId as typeof input.link
|
||||
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(
|
||||
fromPartial<IBaseWidget>({
|
||||
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)
|
||||
const domWidget: 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'
|
||||
}
|
||||
node.addCustomWidget(domWidget as IBaseWidget)
|
||||
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 badWidgetObj: unknown = {
|
||||
name: 'broken',
|
||||
type: 'custom',
|
||||
value: 'x',
|
||||
get options() {
|
||||
throw new Error('bad options')
|
||||
}
|
||||
}
|
||||
node.widgets = [badWidgetObj as IBaseWidget]
|
||||
|
||||
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 badWidgetObj2: unknown = {
|
||||
value: 'x',
|
||||
get options() {
|
||||
throw new Error('bad options')
|
||||
}
|
||||
}
|
||||
node.widgets = [badWidgetObj2 as IBaseWidget]
|
||||
|
||||
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)
|
||||
const rootGraphSpy = 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')
|
||||
rootGraphSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
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 widgetObj: unknown = { linkedWidgets: [linkedControl] }
|
||||
const widget = widgetObj as IBaseWidget
|
||||
|
||||
const control = getControlWidget(widget)
|
||||
|
||||
expect(control?.value).toBe('fixed')
|
||||
|
||||
control?.update('unexpected')
|
||||
|
||||
expect(linkedControl.value).toBe('randomize')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,224 +0,0 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, defineComponent } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
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('@/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
|
||||
})
|
||||
}))
|
||||
|
||||
const i18n = createI18n({ legacy: false, locale: 'en', messages: { en: {} } })
|
||||
|
||||
function withI18n<T>(fn: () => T): T {
|
||||
let result!: T
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup() {
|
||||
result = fn()
|
||||
return () => null
|
||||
}
|
||||
})
|
||||
)
|
||||
app.use(i18n)
|
||||
app.mount(document.createElement('div'))
|
||||
return result
|
||||
}
|
||||
|
||||
function setup() {
|
||||
return withI18n(() => useGroupMenuOptions())
|
||||
}
|
||||
|
||||
function group(over: Record<string, unknown> = {}): LGraphGroup {
|
||||
return fromPartial<LGraphGroup>({
|
||||
recomputeInsideNodes: vi.fn(),
|
||||
resizeTo: vi.fn(),
|
||||
children: [],
|
||||
graph: { change: vi.fn() },
|
||||
nodes: [],
|
||||
...over
|
||||
})
|
||||
}
|
||||
|
||||
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()
|
||||
setup().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')
|
||||
})
|
||||
})
|
||||
setup().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 = setup().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()
|
||||
setup()
|
||||
.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()
|
||||
setup().getGroupColorOptions(g, bump).submenu?.[0].action?.()
|
||||
|
||||
expect(g.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
|
||||
setup().getGroupColorOptions(g, bump).submenu?.[0].action?.()
|
||||
|
||||
expect(g.color).toBe('#eee')
|
||||
expect(bump).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns no mode options for an empty group', () => {
|
||||
expect(setup().getGroupModeOptions(group(), vi.fn())).toEqual([])
|
||||
})
|
||||
|
||||
it('returns no mode options when a group has no nodes array', () => {
|
||||
expect(
|
||||
setup().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 = setup().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)
|
||||
)
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
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 = setup().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 = setup().getGroupModeOptions(
|
||||
group({ nodes: [{ mode: LGraphEventMode.NEVER }] }),
|
||||
vi.fn()
|
||||
)
|
||||
expect(options).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('offers two alternate modes when all nodes are BYPASS', () => {
|
||||
const options = setup().getGroupModeOptions(
|
||||
group({ nodes: [{ mode: LGraphEventMode.BYPASS }] }),
|
||||
vi.fn()
|
||||
)
|
||||
expect(options).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('offers all three modes when nodes have mixed modes', () => {
|
||||
const options = setup().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 = setup().getGroupModeOptions(
|
||||
group({ nodes: [{ mode: 999 }] }),
|
||||
vi.fn()
|
||||
)
|
||||
expect(options).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
@@ -1,58 +1,24 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, defineComponent } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { downloadFile, openFileInNewTab } from '@/base/common/downloadUtil'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import enMessages from '@/locales/en/main.json'
|
||||
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
|
||||
import { useImageMenuOptions } from './useImageMenuOptions'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
function withI18n<T>(fn: () => T): T {
|
||||
let result!: T
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup() {
|
||||
result = fn()
|
||||
return () => null
|
||||
}
|
||||
vi.mock('vue-i18n', async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...(actual as object),
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key.split('.').pop() ?? key
|
||||
})
|
||||
)
|
||||
app.use(i18n)
|
||||
app.mount(document.createElement('div'))
|
||||
return result
|
||||
}
|
||||
|
||||
function setup() {
|
||||
return withI18n(() => useImageMenuOptions())
|
||||
}
|
||||
|
||||
function mockGetContext(
|
||||
ctx: Partial<CanvasRenderingContext2D>
|
||||
): HTMLCanvasElement['getContext'] {
|
||||
const fn: unknown = () => fromPartial<CanvasRenderingContext2D>(ctx)
|
||||
return fn as HTMLCanvasElement['getContext']
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/stores/commandStore', () => ({
|
||||
useCommandStore: () => ({ execute: vi.fn() })
|
||||
}))
|
||||
|
||||
vi.mock('@/base/common/downloadUtil', () => ({
|
||||
downloadFile: vi.fn(),
|
||||
openFileInNewTab: vi.fn()
|
||||
}))
|
||||
|
||||
const hadOriginalClipboard = 'clipboard' in navigator
|
||||
const originalClipboard = navigator.clipboard
|
||||
|
||||
function mockClipboard(clipboard: Partial<Clipboard> | undefined) {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: clipboard,
|
||||
@@ -61,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 {
|
||||
@@ -88,29 +45,14 @@ function createImageNode(
|
||||
}
|
||||
|
||||
describe('useImageMenuOptions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
// Object.defineProperty on navigator isn't undone by unstubAllGlobals.
|
||||
if (hadOriginalClipboard) {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: originalClipboard,
|
||||
writable: true,
|
||||
configurable: true
|
||||
})
|
||||
} else {
|
||||
Reflect.deleteProperty(navigator, 'clipboard')
|
||||
}
|
||||
})
|
||||
|
||||
describe('getImageMenuOptions', () => {
|
||||
it('includes Paste Image option when node supports paste', () => {
|
||||
const node = createImageNode()
|
||||
const { getImageMenuOptions } = setup()
|
||||
const { getImageMenuOptions } = useImageMenuOptions()
|
||||
const options = getImageMenuOptions(node)
|
||||
const labels = options.map((o) => o.label)
|
||||
|
||||
@@ -119,7 +61,7 @@ describe('useImageMenuOptions', () => {
|
||||
|
||||
it('excludes Paste Image option when node does not support paste', () => {
|
||||
const node = createImageNode({ pasteFiles: undefined })
|
||||
const { getImageMenuOptions } = setup()
|
||||
const { getImageMenuOptions } = useImageMenuOptions()
|
||||
const options = getImageMenuOptions(node)
|
||||
const labels = options.map((o) => o.label)
|
||||
|
||||
@@ -128,24 +70,18 @@ describe('useImageMenuOptions', () => {
|
||||
|
||||
it('returns empty array when node has no images and no pasteFiles', () => {
|
||||
const node = createMockLGraphNode({ imgs: [] })
|
||||
const { getImageMenuOptions } = setup()
|
||||
const { getImageMenuOptions } = useImageMenuOptions()
|
||||
|
||||
expect(getImageMenuOptions(node)).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array when node image capabilities are absent', () => {
|
||||
const { getImageMenuOptions } = setup()
|
||||
|
||||
expect(getImageMenuOptions(fromPartial<LGraphNode>({}))).toEqual([])
|
||||
})
|
||||
|
||||
it('returns only Paste Image when node has no images but supports paste', () => {
|
||||
const node = createMockLGraphNode({
|
||||
imgs: [],
|
||||
pasteFile: vi.fn(),
|
||||
pasteFiles: vi.fn()
|
||||
})
|
||||
const { getImageMenuOptions } = setup()
|
||||
const { getImageMenuOptions } = useImageMenuOptions()
|
||||
const options = getImageMenuOptions(node)
|
||||
const labels = options.map((o) => o.label)
|
||||
|
||||
@@ -154,7 +90,7 @@ describe('useImageMenuOptions', () => {
|
||||
|
||||
it('places Paste Image between Copy Image and Save Image', () => {
|
||||
const node = createImageNode()
|
||||
const { getImageMenuOptions } = setup()
|
||||
const { getImageMenuOptions } = useImageMenuOptions()
|
||||
const options = getImageMenuOptions(node)
|
||||
const labels = options.map((o) => o.label)
|
||||
|
||||
@@ -168,7 +104,7 @@ describe('useImageMenuOptions', () => {
|
||||
|
||||
it('gives the Open in Mask Editor option the mask icon', () => {
|
||||
const node = createImageNode()
|
||||
const { getImageMenuOptions } = setup()
|
||||
const { getImageMenuOptions } = useImageMenuOptions()
|
||||
const options = getImageMenuOptions(node)
|
||||
const maskOption = options.find((o) => o.label === 'Open in Mask Editor')
|
||||
|
||||
@@ -177,7 +113,7 @@ describe('useImageMenuOptions', () => {
|
||||
|
||||
it('gives every image action option an icon so labels stay aligned', () => {
|
||||
const node = createImageNode()
|
||||
const { getImageMenuOptions } = setup()
|
||||
const { getImageMenuOptions } = useImageMenuOptions()
|
||||
const options = getImageMenuOptions(node)
|
||||
|
||||
expect(options.every((o) => !!o.icon)).toBe(true)
|
||||
@@ -199,7 +135,7 @@ describe('useImageMenuOptions', () => {
|
||||
})
|
||||
)
|
||||
|
||||
const { getImageMenuOptions } = setup()
|
||||
const { getImageMenuOptions } = useImageMenuOptions()
|
||||
const options = getImageMenuOptions(node)
|
||||
const pasteOption = options.find((o) => o.label === 'Paste Image')
|
||||
|
||||
@@ -216,7 +152,7 @@ describe('useImageMenuOptions', () => {
|
||||
const node = createImageNode()
|
||||
mockClipboard(fromPartial<Clipboard>({ read: undefined }))
|
||||
|
||||
const { getImageMenuOptions } = setup()
|
||||
const { getImageMenuOptions } = useImageMenuOptions()
|
||||
const options = getImageMenuOptions(node)
|
||||
const pasteOption = options.find((o) => o.label === 'Paste Image')
|
||||
|
||||
@@ -237,7 +173,7 @@ describe('useImageMenuOptions', () => {
|
||||
})
|
||||
)
|
||||
|
||||
const { getImageMenuOptions } = setup()
|
||||
const { getImageMenuOptions } = useImageMenuOptions()
|
||||
const options = getImageMenuOptions(node)
|
||||
const pasteOption = options.find((o) => o.label === 'Paste Image')
|
||||
|
||||
@@ -246,213 +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 } = setup()
|
||||
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 } = setup()
|
||||
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 } = setup()
|
||||
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 } = setup()
|
||||
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 } = setup()
|
||||
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 } = setup()
|
||||
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(
|
||||
mockGetContext({ drawImage })
|
||||
)
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation(
|
||||
(callback: BlobCallback) => {
|
||||
callback(new Blob(['image'], { type: 'image/png' }))
|
||||
}
|
||||
)
|
||||
|
||||
const { getImageMenuOptions } = setup()
|
||||
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 } = setup()
|
||||
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(
|
||||
mockGetContext({ drawImage: vi.fn() })
|
||||
)
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation(
|
||||
(callback: BlobCallback) => {
|
||||
callback(null)
|
||||
}
|
||||
)
|
||||
|
||||
const { getImageMenuOptions } = setup()
|
||||
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(
|
||||
mockGetContext({ drawImage: vi.fn() })
|
||||
)
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation(
|
||||
(callback: BlobCallback) => {
|
||||
callback(new Blob(['image'], { type: 'image/png' }))
|
||||
}
|
||||
)
|
||||
|
||||
const { getImageMenuOptions } = setup()
|
||||
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(
|
||||
mockGetContext({ drawImage: vi.fn() })
|
||||
)
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation(
|
||||
(callback: BlobCallback) => {
|
||||
callback(new Blob(['image'], { type: 'image/png' }))
|
||||
}
|
||||
)
|
||||
|
||||
const { getImageMenuOptions } = setup()
|
||||
await getImageMenuOptions(node)
|
||||
.find((o) => o.label === 'Copy Image')
|
||||
?.action?.()
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
'Failed to copy image to clipboard:',
|
||||
expect.any(Error)
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,306 +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: (states: { pinned: boolean }) => ({
|
||||
label: states.pinned ? 'Unpin' : '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)
|
||||
// hoveredWidget is a module-level singleton; clear it via the same
|
||||
// public API that sets it so hover state can't leak across tests.
|
||||
showNodeOptions(new MouseEvent('contextmenu'))
|
||||
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 options after a manual bump reflects new selection flags', () => {
|
||||
selectionState.computeSelectionFlags.mockReturnValue({
|
||||
collapsed: false,
|
||||
pinned: false
|
||||
})
|
||||
const { bump, menuOptions } = useMoreOptionsMenu()
|
||||
expect(menuOptions.value.map((o) => o.label)).toContain('Pin')
|
||||
|
||||
selectionState.computeSelectionFlags.mockReturnValue({
|
||||
collapsed: false,
|
||||
pinned: true
|
||||
})
|
||||
bump()
|
||||
const labelsAfterBump = menuOptions.value.map((o) => o.label)
|
||||
expect(labelsAfterBump).toContain('Unpin')
|
||||
expect(labelsAfterBump).not.toContain('Pin')
|
||||
})
|
||||
|
||||
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)
|
||||
)
|
||||
errorSpy.mockRestore()
|
||||
})
|
||||
|
||||
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,198 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, defineComponent } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
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('@/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 })
|
||||
}))
|
||||
|
||||
const i18n = createI18n({ legacy: false, locale: 'en', messages: { en: {} } })
|
||||
|
||||
function withI18n<T>(fn: () => T): T {
|
||||
let result!: T
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup() {
|
||||
result = fn()
|
||||
return () => null
|
||||
}
|
||||
})
|
||||
)
|
||||
app.use(i18n)
|
||||
app.mount(document.createElement('div'))
|
||||
return result
|
||||
}
|
||||
|
||||
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 } = withI18n(() =>
|
||||
useNodeCustomization()
|
||||
)
|
||||
expect(colorOptions.length).toBeGreaterThan(1)
|
||||
expect(shapeOptions.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('reflects the active palette light-theme flag', () => {
|
||||
palette.light_theme = true
|
||||
expect(withI18n(() => useNodeCustomization()).isLightTheme.value).toBe(true)
|
||||
})
|
||||
|
||||
it('clears color on all colorable items for the no-color option', () => {
|
||||
const item = colorable()
|
||||
selection.items = [item]
|
||||
withI18n(() => 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 } = withI18n(() => 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]
|
||||
|
||||
withI18n(() => useNodeCustomization()).applyColor(null)
|
||||
|
||||
expect(item.setColorOption).toHaveBeenCalledWith(null)
|
||||
expect(refreshCanvas).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns null current color for an empty selection', () => {
|
||||
expect(withI18n(() => useNodeCustomization()).getCurrentColor()).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null current color when no selected item is colorable', () => {
|
||||
selection.items = [{}]
|
||||
expect(withI18n(() => useNodeCustomization()).getCurrentColor()).toBeNull()
|
||||
})
|
||||
|
||||
it('reports a recognized current color', () => {
|
||||
const { colorOptions, getCurrentColor } = withI18n(() =>
|
||||
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 = withI18n(() => useNodeCustomization()).getCurrentColor()
|
||||
expect(result?.name).toBe('noColor')
|
||||
})
|
||||
|
||||
it('no-ops shape changes when no graph nodes are selected', () => {
|
||||
selection.items = [colorable()]
|
||||
const { applyShape, shapeOptions } = withI18n(() => useNodeCustomization())
|
||||
applyShape(shapeOptions[0])
|
||||
expect(refreshCanvas).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns null current shape with no nodes selected', () => {
|
||||
expect(withI18n(() => useNodeCustomization()).getCurrentShape()).toBeNull()
|
||||
})
|
||||
|
||||
it('applies a shape to selected graph nodes and refreshes', () => {
|
||||
const node = new LGraphNode('Test')
|
||||
selection.items = [node]
|
||||
const { applyShape, shapeOptions } = withI18n(() => 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 } = withI18n(() =>
|
||||
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 } = withI18n(() =>
|
||||
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 } = withI18n(() =>
|
||||
useNodeCustomization()
|
||||
)
|
||||
selection.items = [node]
|
||||
|
||||
expect(getCurrentShape()?.value).toBe(shapeOptions[0].value)
|
||||
})
|
||||
})
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -1,342 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type * as VueUse from '@vueuse/core'
|
||||
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { UNASSIGNED_NODE_ID, toNodeId } from '@/types/nodeId'
|
||||
|
||||
type MockReroute = {
|
||||
id: string
|
||||
pos: [number, number]
|
||||
parentId?: string | null
|
||||
linkIds: Set<string>
|
||||
}
|
||||
|
||||
type MockLink = {
|
||||
id: string
|
||||
origin_id: string
|
||||
origin_slot: number
|
||||
target_id: string
|
||||
target_slot: number
|
||||
}
|
||||
|
||||
type MockGraph = {
|
||||
_nodes: LGraphNode[]
|
||||
reroutes: Map<string, MockReroute>
|
||||
_links: Map<string, MockLink>
|
||||
onNodeAdded?: (node: LGraphNode) => void
|
||||
}
|
||||
|
||||
type MockCanvas = {
|
||||
graph?: MockGraph
|
||||
setDirty: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
const mockWheneverCallbacks = vi.hoisted(() => ({
|
||||
values: [] as Array<() => void>
|
||||
}))
|
||||
|
||||
vi.mock('@vueuse/core', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof VueUse>()
|
||||
return {
|
||||
...actual,
|
||||
createSharedComposable: <Args extends unknown[], Return>(
|
||||
fn: (...args: Args) => Return
|
||||
) => fn,
|
||||
whenever: (_source: () => boolean, callback: () => void) => {
|
||||
mockWheneverCallbacks.values.push(callback)
|
||||
return vi.fn()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const mockUseGraphNodeManager = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/composables/graph/useGraphNodeManager', () => ({
|
||||
useGraphNodeManager: mockUseGraphNodeManager
|
||||
}))
|
||||
|
||||
const mockShouldRenderVueNodes = vi.hoisted(() => ({ value: false }))
|
||||
vi.mock('@/composables/useVueFeatureFlags', () => ({
|
||||
useVueFeatureFlags: () => ({
|
||||
shouldRenderVueNodes: mockShouldRenderVueNodes
|
||||
})
|
||||
}))
|
||||
|
||||
const mockCanvasStoreCanvas = vi.hoisted(() => ({
|
||||
value: undefined as MockCanvas | undefined
|
||||
}))
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({
|
||||
canvas: mockCanvasStoreCanvas.value
|
||||
})
|
||||
}))
|
||||
|
||||
const mockCreateReroute = vi.hoisted(() => vi.fn())
|
||||
const mockCreateLink = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/renderer/core/layout/operations/layoutMutations', () => ({
|
||||
useLayoutMutations: () => ({
|
||||
createReroute: mockCreateReroute,
|
||||
createLink: mockCreateLink
|
||||
})
|
||||
}))
|
||||
|
||||
const mockInitializeFromLiteGraph = vi.hoisted(() => vi.fn())
|
||||
const mockClearAllSlotLayouts = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
|
||||
layoutStore: {
|
||||
initializeFromLiteGraph: mockInitializeFromLiteGraph,
|
||||
clearAllSlotLayouts: mockClearAllSlotLayouts
|
||||
}
|
||||
}))
|
||||
|
||||
const mockStartSync = vi.hoisted(() => vi.fn())
|
||||
const mockStopSync = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/renderer/core/layout/sync/useLayoutSync', () => ({
|
||||
useLayoutSync: () => ({
|
||||
startSync: mockStartSync,
|
||||
stopSync: mockStopSync
|
||||
})
|
||||
}))
|
||||
|
||||
const mockComfyCanvas = vi.hoisted(() => ({
|
||||
value: undefined as MockCanvas | undefined
|
||||
}))
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
get canvas() {
|
||||
return mockComfyCanvas.value
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
const mockManagerCleanup = vi.hoisted(() => vi.fn())
|
||||
|
||||
function createNode(id: number, overrides: object = {}): LGraphNode {
|
||||
return createMockLGraphNode({
|
||||
id: toNodeId(id),
|
||||
pos: [id * 10, id * 20],
|
||||
size: [100 + id, 200 + id],
|
||||
flags: { collapsed: false },
|
||||
arrange: vi.fn(),
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
|
||||
function createGraph(overrides: Partial<MockGraph> = {}): MockGraph {
|
||||
return {
|
||||
_nodes: [],
|
||||
reroutes: new Map(),
|
||||
_links: new Map(),
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLifecycle() {
|
||||
vi.resetModules()
|
||||
const { useVueNodeLifecycle } = await import('./useVueNodeLifecycle')
|
||||
return useVueNodeLifecycle()
|
||||
}
|
||||
|
||||
describe('useVueNodeLifecycle', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockWheneverCallbacks.values = []
|
||||
mockShouldRenderVueNodes.value = false
|
||||
mockCanvasStoreCanvas.value = undefined
|
||||
mockComfyCanvas.value = undefined
|
||||
mockManagerCleanup.mockReset()
|
||||
mockUseGraphNodeManager.mockReset()
|
||||
mockUseGraphNodeManager.mockReturnValue({ cleanup: mockManagerCleanup })
|
||||
})
|
||||
|
||||
it('initializes the node manager from the active graph', async () => {
|
||||
const node = createNode(1)
|
||||
const graph = createGraph({
|
||||
_nodes: [node],
|
||||
reroutes: new Map([
|
||||
[
|
||||
'reroute-1',
|
||||
{
|
||||
id: 'reroute-1',
|
||||
pos: [12, 34],
|
||||
parentId: null,
|
||||
linkIds: new Set(['link-1'])
|
||||
}
|
||||
]
|
||||
]),
|
||||
_links: new Map([
|
||||
[
|
||||
'link-1',
|
||||
{
|
||||
id: 'link-1',
|
||||
origin_id: toNodeId(1),
|
||||
origin_slot: 0,
|
||||
target_id: toNodeId(2),
|
||||
target_slot: 1
|
||||
}
|
||||
],
|
||||
[
|
||||
'link-2',
|
||||
{
|
||||
id: 'link-2',
|
||||
origin_id: UNASSIGNED_NODE_ID,
|
||||
origin_slot: 0,
|
||||
target_id: toNodeId(2),
|
||||
target_slot: 1
|
||||
}
|
||||
],
|
||||
[
|
||||
'link-3',
|
||||
{
|
||||
id: 'link-3',
|
||||
origin_id: toNodeId(1),
|
||||
origin_slot: 0,
|
||||
target_id: UNASSIGNED_NODE_ID,
|
||||
target_slot: 1
|
||||
}
|
||||
]
|
||||
])
|
||||
})
|
||||
const canvas = { graph, setDirty: vi.fn() }
|
||||
mockComfyCanvas.value = canvas
|
||||
mockCanvasStoreCanvas.value = canvas
|
||||
mockShouldRenderVueNodes.value = true
|
||||
|
||||
const lifecycle = await loadLifecycle()
|
||||
|
||||
expect(mockUseGraphNodeManager).toHaveBeenCalledWith(graph)
|
||||
expect(lifecycle.nodeManager.value).toEqual({
|
||||
cleanup: mockManagerCleanup
|
||||
})
|
||||
expect(mockInitializeFromLiteGraph).toHaveBeenCalledWith([
|
||||
{
|
||||
id: toNodeId(1),
|
||||
pos: [10, 20],
|
||||
size: [101, 201]
|
||||
}
|
||||
])
|
||||
expect(mockCreateReroute).toHaveBeenCalledWith(
|
||||
'reroute-1',
|
||||
{ x: 12, y: 34 },
|
||||
undefined,
|
||||
['link-1']
|
||||
)
|
||||
expect(mockCreateLink).toHaveBeenCalledOnce()
|
||||
expect(mockCreateLink).toHaveBeenCalledWith(
|
||||
'link-1',
|
||||
toNodeId(1),
|
||||
0,
|
||||
toNodeId(2),
|
||||
1
|
||||
)
|
||||
expect(mockStartSync).toHaveBeenCalledWith(canvas)
|
||||
})
|
||||
|
||||
it('does not initialize without an active graph', async () => {
|
||||
mockShouldRenderVueNodes.value = true
|
||||
const lifecycle = await loadLifecycle()
|
||||
|
||||
lifecycle.initializeNodeManager()
|
||||
|
||||
expect(mockUseGraphNodeManager).not.toHaveBeenCalled()
|
||||
expect(mockStartSync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stops sync and tolerates manager cleanup errors', async () => {
|
||||
mockManagerCleanup.mockImplementation(() => {
|
||||
throw new Error('cleanup failed')
|
||||
})
|
||||
mockComfyCanvas.value = {
|
||||
graph: createGraph(),
|
||||
setDirty: vi.fn()
|
||||
}
|
||||
mockShouldRenderVueNodes.value = true
|
||||
const lifecycle = await loadLifecycle()
|
||||
|
||||
expect(() => lifecycle.disposeNodeManagerAndSyncs()).not.toThrow()
|
||||
|
||||
expect(mockStopSync).toHaveBeenCalled()
|
||||
expect(lifecycle.nodeManager.value).toBeNull()
|
||||
})
|
||||
|
||||
it('arranges legacy nodes when the Vue node mode is disabled', async () => {
|
||||
const arrangeVisible = vi.fn()
|
||||
const arrangeThrowing = vi.fn(() => {
|
||||
throw new Error('not ready')
|
||||
})
|
||||
const graph = createGraph({
|
||||
_nodes: [
|
||||
createNode(1, { arrange: arrangeVisible }),
|
||||
createNode(2, { flags: { collapsed: true }, arrange: vi.fn() }),
|
||||
createNode(3, { arrange: arrangeThrowing })
|
||||
]
|
||||
})
|
||||
const canvas = { graph, setDirty: vi.fn() }
|
||||
mockComfyCanvas.value = canvas
|
||||
mockShouldRenderVueNodes.value = true
|
||||
await loadLifecycle()
|
||||
|
||||
mockWheneverCallbacks.values[0]()
|
||||
|
||||
expect(arrangeVisible).toHaveBeenCalled()
|
||||
expect(arrangeThrowing).toHaveBeenCalled()
|
||||
expect(canvas.setDirty).toHaveBeenCalledWith(true, true)
|
||||
})
|
||||
|
||||
it('marks the canvas dirty when disabling without a graph', async () => {
|
||||
const canvas = { setDirty: vi.fn() }
|
||||
mockComfyCanvas.value = canvas
|
||||
await loadLifecycle()
|
||||
|
||||
mockWheneverCallbacks.values[0]()
|
||||
|
||||
expect(canvas.setDirty).toHaveBeenCalledWith(true, true)
|
||||
})
|
||||
|
||||
it('initializes on the first node added to an empty graph', async () => {
|
||||
mockShouldRenderVueNodes.value = true
|
||||
const originalOnNodeAdded = vi.fn()
|
||||
const graph = createGraph({ onNodeAdded: originalOnNodeAdded })
|
||||
const canvas = { graph, setDirty: vi.fn() }
|
||||
const node = createNode(1)
|
||||
const lifecycle = await loadLifecycle()
|
||||
mockComfyCanvas.value = canvas
|
||||
|
||||
lifecycle.setupEmptyGraphListener()
|
||||
graph.onNodeAdded?.(node)
|
||||
|
||||
expect(mockUseGraphNodeManager).toHaveBeenCalledWith(graph)
|
||||
expect(graph.onNodeAdded).toBe(originalOnNodeAdded)
|
||||
expect(originalOnNodeAdded).toHaveBeenCalledWith(node)
|
||||
})
|
||||
|
||||
it('does not replace onNodeAdded when the empty-graph guard fails', async () => {
|
||||
const originalOnNodeAdded = vi.fn()
|
||||
const graph = createGraph({
|
||||
_nodes: [createNode(1)],
|
||||
onNodeAdded: originalOnNodeAdded
|
||||
})
|
||||
mockComfyCanvas.value = { graph, setDirty: vi.fn() }
|
||||
mockShouldRenderVueNodes.value = true
|
||||
const lifecycle = await loadLifecycle()
|
||||
|
||||
lifecycle.setupEmptyGraphListener()
|
||||
|
||||
expect(graph.onNodeAdded).toBe(originalOnNodeAdded)
|
||||
})
|
||||
|
||||
it('cleans up the node manager on unmount', async () => {
|
||||
mockComfyCanvas.value = {
|
||||
graph: createGraph(),
|
||||
setDirty: vi.fn()
|
||||
}
|
||||
mockShouldRenderVueNodes.value = true
|
||||
const lifecycle = await loadLifecycle()
|
||||
|
||||
lifecycle.cleanup()
|
||||
lifecycle.cleanup()
|
||||
|
||||
expect(mockManagerCleanup).toHaveBeenCalledOnce()
|
||||
expect(lifecycle.nodeManager.value).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -23,8 +23,8 @@ const mockStore = reactive({
|
||||
gpuTexturesNeedRecreation: false,
|
||||
gpuTextureWidth: 0,
|
||||
gpuTextureHeight: 0,
|
||||
pendingGPUMaskData: null as Uint8Array | null,
|
||||
pendingGPURgbData: null as Uint8Array | null,
|
||||
pendingGPUMaskData: null as null,
|
||||
pendingGPURgbData: null as null,
|
||||
brushSettings: {
|
||||
size: 20,
|
||||
hardness: 0.9,
|
||||
@@ -42,143 +42,18 @@ vi.mock('@/stores/maskEditorStore', () => ({
|
||||
useMaskEditorStore: vi.fn(() => mockStore)
|
||||
}))
|
||||
|
||||
import { tgpu } from 'typegpu'
|
||||
|
||||
import { GPUBrushRenderer } from './gpu/GPUBrushRenderer'
|
||||
import { resetDirtyRect } from './brushDrawingUtils'
|
||||
import { useGPUResources } from './useGPUResources'
|
||||
|
||||
let scope: EffectScope | null = null
|
||||
const hadOriginalNavigatorGpu = 'gpu' in navigator
|
||||
const originalNavigatorGpu = (navigator as { gpu?: unknown }).gpu
|
||||
|
||||
function setup() {
|
||||
scope = effectScope()
|
||||
return scope.run(() => useGPUResources())!
|
||||
}
|
||||
|
||||
class TestImageData {
|
||||
data: Uint8ClampedArray
|
||||
width: number
|
||||
height: number
|
||||
|
||||
constructor(data: Uint8ClampedArray, width: number, height: number) {
|
||||
this.data = data
|
||||
this.width = width
|
||||
this.height = height
|
||||
}
|
||||
}
|
||||
|
||||
function createMockTexture(): GPUTexture {
|
||||
const obj: unknown = {
|
||||
createView: vi.fn(() => ({})),
|
||||
destroy: vi.fn()
|
||||
}
|
||||
return obj as GPUTexture
|
||||
}
|
||||
|
||||
function createMockBuffer(byteLength = 16): GPUBuffer {
|
||||
const obj: unknown = {
|
||||
destroy: vi.fn(),
|
||||
mapAsync: vi.fn().mockResolvedValue(undefined),
|
||||
getMappedRange: vi.fn(() => new Uint8Array(byteLength).buffer),
|
||||
unmap: vi.fn()
|
||||
}
|
||||
return obj as GPUBuffer
|
||||
}
|
||||
|
||||
function createMockDevice(): GPUDevice {
|
||||
const obj: unknown = {
|
||||
limits: {},
|
||||
queue: {
|
||||
writeTexture: vi.fn(),
|
||||
submit: vi.fn()
|
||||
},
|
||||
createTexture: vi.fn(() => createMockTexture()),
|
||||
createBuffer: vi.fn(() => createMockBuffer()),
|
||||
createCommandEncoder: vi.fn(() => ({
|
||||
copyBufferToBuffer: vi.fn(),
|
||||
finish: vi.fn(() => ({}))
|
||||
}))
|
||||
}
|
||||
return obj as GPUDevice
|
||||
}
|
||||
|
||||
function createMockRenderer() {
|
||||
return {
|
||||
destroy: vi.fn(),
|
||||
prepareStroke: vi.fn(),
|
||||
clearPreview: vi.fn(),
|
||||
compositeStroke: vi.fn(),
|
||||
prepareReadback: vi.fn(),
|
||||
renderStrokeToAccumulator: vi.fn(),
|
||||
blitToCanvas: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
function mockGpuBrushRenderer(renderer: ReturnType<typeof createMockRenderer>) {
|
||||
vi.mocked(GPUBrushRenderer).mockImplementation(
|
||||
function GPUBrushRendererMock() {
|
||||
const r: unknown = renderer
|
||||
return r as GPUBrushRenderer
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function createCanvasContext(
|
||||
width: number,
|
||||
height: number
|
||||
): CanvasRenderingContext2D {
|
||||
const obj: unknown = {
|
||||
globalCompositeOperation: 'source-over',
|
||||
getImageData: vi.fn(
|
||||
() =>
|
||||
new ImageData(new Uint8ClampedArray(width * height * 4), width, height)
|
||||
),
|
||||
putImageData: vi.fn()
|
||||
}
|
||||
return obj as CanvasRenderingContext2D
|
||||
}
|
||||
|
||||
function setReadyCanvases(width = 2, height = 2) {
|
||||
const maskCanvas = document.createElement('canvas')
|
||||
maskCanvas.width = width
|
||||
maskCanvas.height = height
|
||||
const rgbCanvas = document.createElement('canvas')
|
||||
rgbCanvas.width = width
|
||||
rgbCanvas.height = height
|
||||
|
||||
mockStore.maskCanvas = maskCanvas
|
||||
mockStore.rgbCanvas = rgbCanvas
|
||||
mockStore.maskCtx = createCanvasContext(width, height)
|
||||
mockStore.rgbCtx = createCanvasContext(width, height)
|
||||
}
|
||||
|
||||
function installGpuGlobals() {
|
||||
vi.stubGlobal('GPUTextureUsage', {
|
||||
TEXTURE_BINDING: 1,
|
||||
STORAGE_BINDING: 2,
|
||||
RENDER_ATTACHMENT: 4,
|
||||
COPY_DST: 8,
|
||||
COPY_SRC: 16
|
||||
})
|
||||
vi.stubGlobal('GPUBufferUsage', {
|
||||
STORAGE: 1,
|
||||
COPY_SRC: 2,
|
||||
COPY_DST: 4,
|
||||
MAP_READ: 8
|
||||
})
|
||||
vi.stubGlobal('GPUMapMode', { READ: 1 })
|
||||
vi.stubGlobal('ImageData', TestImageData)
|
||||
Object.defineProperty(navigator, 'gpu', {
|
||||
value: { getPreferredCanvasFormat: vi.fn(() => 'rgba8unorm') },
|
||||
configurable: true
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
installGpuGlobals()
|
||||
mockStore.tgpuRoot = null
|
||||
mockStore.maskCanvas = null
|
||||
mockStore.rgbCanvas = null
|
||||
@@ -187,37 +62,11 @@ beforeEach(() => {
|
||||
mockStore.clearTrigger = 0
|
||||
mockStore.canvasHistory.currentStateIndex = 0
|
||||
mockStore.gpuTexturesNeedRecreation = false
|
||||
mockStore.gpuTextureWidth = 0
|
||||
mockStore.gpuTextureHeight = 0
|
||||
mockStore.pendingGPUMaskData = null
|
||||
mockStore.pendingGPURgbData = null
|
||||
mockStore.activeLayer = 'mask'
|
||||
mockStore.currentTool = 'pen'
|
||||
mockStore.maskColor = { r: 0, g: 0, b: 0 }
|
||||
mockStore.rgbColor = '#FF0000'
|
||||
mockStore.brushSettings = {
|
||||
size: 20,
|
||||
hardness: 0.9,
|
||||
opacity: 1,
|
||||
stepSize: 5,
|
||||
type: 'arc'
|
||||
}
|
||||
vi.mocked(tgpu.init).mockRejectedValue(new Error('WebGPU not supported'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
scope?.stop()
|
||||
scope = null
|
||||
vi.unstubAllGlobals()
|
||||
// Object.defineProperty on navigator isn't undone by unstubAllGlobals.
|
||||
if (hadOriginalNavigatorGpu) {
|
||||
Object.defineProperty(navigator, 'gpu', {
|
||||
value: originalNavigatorGpu,
|
||||
configurable: true
|
||||
})
|
||||
} else {
|
||||
Reflect.deleteProperty(navigator, 'gpu')
|
||||
}
|
||||
})
|
||||
|
||||
describe('initial reactive state', () => {
|
||||
@@ -282,34 +131,6 @@ describe('initGPUResources', () => {
|
||||
await initGPUResources()
|
||||
expect(hasRenderer.value).toBe(false)
|
||||
})
|
||||
|
||||
it('handles non-error TypeGPU initialisation failures', async () => {
|
||||
vi.mocked(tgpu.init).mockRejectedValueOnce('WebGPU unavailable')
|
||||
|
||||
const { initGPUResources, hasRenderer } = setup()
|
||||
await initGPUResources()
|
||||
|
||||
expect(hasRenderer.value).toBe(false)
|
||||
})
|
||||
|
||||
it('initializes renderer when a root and canvas contexts are ready', async () => {
|
||||
const device = createMockDevice()
|
||||
const renderer = createMockRenderer()
|
||||
mockStore.tgpuRoot = {
|
||||
device,
|
||||
destroy: vi.fn()
|
||||
}
|
||||
setReadyCanvases()
|
||||
mockGpuBrushRenderer(renderer)
|
||||
|
||||
const { initGPUResources, hasRenderer } = setup()
|
||||
await initGPUResources()
|
||||
|
||||
expect(hasRenderer.value).toBe(true)
|
||||
expect(device.createTexture).toHaveBeenCalledTimes(2)
|
||||
expect(device.queue.writeTexture).toHaveBeenCalledTimes(2)
|
||||
expect(GPUBrushRenderer).toHaveBeenCalledWith(device, 'rgba8unorm')
|
||||
})
|
||||
})
|
||||
|
||||
describe('copyGpuToCanvas', () => {
|
||||
@@ -353,18 +174,6 @@ describe('initGPUResources with pre-existing tgpuRoot', () => {
|
||||
await initGPUResources()
|
||||
expect(hasRenderer.value).toBe(false)
|
||||
})
|
||||
|
||||
it('texture recreation watcher returns early when mask canvas is missing', async () => {
|
||||
const device = createMockDevice()
|
||||
const { initGPUResources } = setup()
|
||||
mockStore.tgpuRoot = { device, destroy: vi.fn() }
|
||||
|
||||
await initGPUResources()
|
||||
mockStore.gpuTexturesNeedRecreation = true
|
||||
await nextTick()
|
||||
|
||||
expect(device.createTexture).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('initPreviewCanvas', () => {
|
||||
@@ -373,47 +182,6 @@ describe('initPreviewCanvas', () => {
|
||||
const canvas = document.createElement('canvas')
|
||||
expect(() => initPreviewCanvas(canvas)).not.toThrow()
|
||||
})
|
||||
|
||||
it('returns early when a WebGPU canvas context is unavailable', async () => {
|
||||
const device = createMockDevice()
|
||||
mockStore.tgpuRoot = { device, destroy: vi.fn() }
|
||||
setReadyCanvases()
|
||||
mockGpuBrushRenderer(createMockRenderer())
|
||||
|
||||
const { initGPUResources, initPreviewCanvas, previewCanvas } = setup()
|
||||
await initGPUResources()
|
||||
const canvas = document.createElement('canvas')
|
||||
Object.defineProperty(canvas, 'getContext', { value: vi.fn(() => null) })
|
||||
|
||||
initPreviewCanvas(canvas)
|
||||
|
||||
expect(previewCanvas.value).toBeNull()
|
||||
})
|
||||
|
||||
it('stores the preview canvas when a WebGPU context is available', async () => {
|
||||
const device = createMockDevice()
|
||||
const renderer = createMockRenderer()
|
||||
const previewContext = { configure: vi.fn() }
|
||||
mockStore.tgpuRoot = { device, destroy: vi.fn() }
|
||||
setReadyCanvases()
|
||||
mockGpuBrushRenderer(renderer)
|
||||
|
||||
const { initGPUResources, initPreviewCanvas, previewCanvas } = setup()
|
||||
await initGPUResources()
|
||||
const canvas = document.createElement('canvas')
|
||||
Object.defineProperty(canvas, 'getContext', {
|
||||
value: vi.fn(() => previewContext)
|
||||
})
|
||||
|
||||
initPreviewCanvas(canvas)
|
||||
|
||||
expect(previewContext.configure).toHaveBeenCalledWith({
|
||||
device,
|
||||
format: 'rgba8unorm',
|
||||
alphaMode: 'premultiplied'
|
||||
})
|
||||
expect(previewCanvas.value).toBe(canvas)
|
||||
})
|
||||
})
|
||||
|
||||
describe('gpuDrawPoint', () => {
|
||||
@@ -421,70 +189,4 @@ describe('gpuDrawPoint', () => {
|
||||
const { gpuDrawPoint } = setup()
|
||||
await expect(gpuDrawPoint({ x: 10, y: 20 })).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('delegates renderer operations when GPU resources are initialized', async () => {
|
||||
const device = createMockDevice()
|
||||
const renderer = createMockRenderer()
|
||||
const previewContext = { configure: vi.fn() }
|
||||
mockStore.tgpuRoot = { device, destroy: vi.fn() }
|
||||
setReadyCanvases()
|
||||
mockGpuBrushRenderer(renderer)
|
||||
|
||||
const resources = setup()
|
||||
await resources.initGPUResources()
|
||||
const canvas = document.createElement('canvas')
|
||||
Object.defineProperty(canvas, 'getContext', {
|
||||
value: vi.fn(() => previewContext)
|
||||
})
|
||||
resources.initPreviewCanvas(canvas)
|
||||
|
||||
resources.prepareStroke()
|
||||
resources.clearPreview()
|
||||
resources.compositeStroke(false, false)
|
||||
resources.gpuRender([{ x: 1, y: 1 }])
|
||||
await resources.gpuDrawPoint({ x: 1, y: 1 })
|
||||
|
||||
expect(renderer.prepareStroke).toHaveBeenCalledWith(2, 2)
|
||||
expect(renderer.clearPreview).toHaveBeenCalledWith(previewContext)
|
||||
expect(renderer.compositeStroke).toHaveBeenCalled()
|
||||
expect(renderer.renderStrokeToAccumulator).toHaveBeenCalled()
|
||||
expect(renderer.blitToCanvas).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('copies initialized GPU readback data to canvases', async () => {
|
||||
const device = createMockDevice()
|
||||
const renderer = createMockRenderer()
|
||||
mockStore.tgpuRoot = { device, destroy: vi.fn() }
|
||||
setReadyCanvases()
|
||||
mockGpuBrushRenderer(renderer)
|
||||
|
||||
const resources = setup()
|
||||
await resources.initGPUResources()
|
||||
const result = await resources.copyGpuToCanvas()
|
||||
|
||||
expect(result.maskData.width).toBe(2)
|
||||
expect(result.rgbData.height).toBe(2)
|
||||
expect(renderer.prepareReadback).toHaveBeenCalledTimes(2)
|
||||
expect(mockStore.maskCtx?.putImageData).toHaveBeenCalled()
|
||||
expect(mockStore.rgbCtx?.putImageData).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('destroys initialized GPU resources and root state', async () => {
|
||||
const device = createMockDevice()
|
||||
const renderer = createMockRenderer()
|
||||
const root = { device, destroy: vi.fn() }
|
||||
mockStore.tgpuRoot = root
|
||||
setReadyCanvases()
|
||||
mockGpuBrushRenderer(renderer)
|
||||
|
||||
const { initGPUResources, destroy, hasRenderer } = setup()
|
||||
await initGPUResources()
|
||||
|
||||
destroy()
|
||||
|
||||
expect(renderer.destroy).toHaveBeenCalled()
|
||||
expect(root.destroy).toHaveBeenCalled()
|
||||
expect(mockStore.tgpuRoot).toBeNull()
|
||||
expect(hasRenderer.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,415 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
extractWidgetStringValue,
|
||||
useMaskEditorLoader
|
||||
} from '@/composables/maskeditor/useMaskEditorLoader'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
interface MockInputData {
|
||||
baseLayer: { url: string }
|
||||
maskLayer: { url: string }
|
||||
paintLayer?: { url: string }
|
||||
sourceRef: { filename: string; subfolder?: string; type?: string }
|
||||
nodeId: unknown
|
||||
}
|
||||
|
||||
const mockDataStore = vi.hoisted(() => ({
|
||||
inputData: undefined as unknown,
|
||||
sourceNode: undefined as unknown,
|
||||
setLoading: vi.fn()
|
||||
}))
|
||||
|
||||
const mockNodeOutputStore = vi.hoisted(() => ({
|
||||
getNodeOutputs: vi.fn()
|
||||
}))
|
||||
|
||||
const mockCloudState = vi.hoisted(() => ({
|
||||
isCloud: false
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/maskEditorDataStore', () => ({
|
||||
useMaskEditorDataStore: () => mockDataStore
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/nodeOutputStore', () => ({
|
||||
useNodeOutputStore: () => mockNodeOutputStore
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return mockCloudState.isCloud
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
apiURL: vi.fn((path: string) => `http://comfy.test${path}`),
|
||||
fetchApi: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
getPreviewFormatParam: vi.fn(() => '&preview=png'),
|
||||
getRandParam: vi.fn(() => '&rand=1')
|
||||
}
|
||||
}))
|
||||
|
||||
function createMockImageClass(handler: 'onload' | 'onerror') {
|
||||
return class {
|
||||
crossOrigin = ''
|
||||
onerror: (() => void) | null = null
|
||||
onload: (() => void) | null = null
|
||||
private imageSrc = ''
|
||||
|
||||
get src() {
|
||||
return this.imageSrc
|
||||
}
|
||||
|
||||
set src(value: string) {
|
||||
this.imageSrc = value
|
||||
queueMicrotask(() => this[handler]?.())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const MockImage = createMockImageClass('onload')
|
||||
|
||||
function makeNode(overrides: object = {}): LGraphNode {
|
||||
const imgObj: unknown = {
|
||||
src: 'http://images.test/render.png?filename=render.png'
|
||||
}
|
||||
const node: unknown = {
|
||||
id: 42,
|
||||
imgs: [imgObj as HTMLImageElement],
|
||||
imageIndex: 0,
|
||||
...overrides
|
||||
}
|
||||
return node as LGraphNode
|
||||
}
|
||||
|
||||
function getInputData(): MockInputData {
|
||||
return mockDataStore.inputData as MockInputData
|
||||
}
|
||||
|
||||
describe('extractWidgetStringValue', () => {
|
||||
it('extracts strings and filename objects', () => {
|
||||
expect(extractWidgetStringValue('image.png')).toBe('image.png')
|
||||
expect(extractWidgetStringValue({ filename: 'object.png' })).toBe(
|
||||
'object.png'
|
||||
)
|
||||
expect(extractWidgetStringValue({ filename: 123 })).toBeUndefined()
|
||||
expect(extractWidgetStringValue(null)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useMaskEditorLoader', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('Image', MockImage)
|
||||
vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
vi.mocked(api.apiURL).mockClear()
|
||||
vi.mocked(api.fetchApi).mockReset()
|
||||
mockDataStore.inputData = undefined
|
||||
mockDataStore.sourceNode = undefined
|
||||
mockDataStore.setLoading.mockClear()
|
||||
mockNodeOutputStore.getNodeOutputs.mockReset()
|
||||
mockCloudState.isCloud = false
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('loads base and mask layers from a node image reference', async () => {
|
||||
const node = makeNode({
|
||||
images: [
|
||||
{
|
||||
filename: 'node-output.png',
|
||||
subfolder: 'outputs',
|
||||
type: 'output'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
await useMaskEditorLoader().loadFromNode(node)
|
||||
|
||||
expect(mockDataStore.setLoading).toHaveBeenNthCalledWith(1, true)
|
||||
expect(mockDataStore.setLoading).toHaveBeenLastCalledWith(false)
|
||||
expect(mockDataStore.sourceNode).toBe(node)
|
||||
expect(mockDataStore.inputData).toMatchObject({
|
||||
nodeId: 42,
|
||||
sourceRef: {
|
||||
filename: 'node-output.png',
|
||||
subfolder: 'outputs',
|
||||
type: 'output'
|
||||
},
|
||||
paintLayer: undefined
|
||||
})
|
||||
expect(getInputData().baseLayer.url).toContain('channel=rgb')
|
||||
expect(getInputData().maskLayer.url).toContain('channel=a')
|
||||
})
|
||||
|
||||
it('uses a concrete image widget value instead of a stale node image', async () => {
|
||||
await useMaskEditorLoader().loadFromNode(
|
||||
makeNode({
|
||||
images: [{ filename: 'stale.png', type: 'output', subfolder: '' }],
|
||||
widgets: [
|
||||
{
|
||||
name: 'image',
|
||||
value: 'clipspace/current.png [input]'
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
expect(getInputData().sourceRef).toMatchObject({
|
||||
filename: 'current.png',
|
||||
subfolder: 'clipspace',
|
||||
type: 'input'
|
||||
})
|
||||
expect(getInputData().baseLayer.url).toContain('filename=current.png')
|
||||
})
|
||||
|
||||
it('keeps internal widget references from replacing the node image', async () => {
|
||||
await useMaskEditorLoader().loadFromNode(
|
||||
makeNode({
|
||||
images: [{ filename: 'real-output.png', type: 'output' }],
|
||||
widgets: [{ name: 'image', value: '$35-0' }]
|
||||
})
|
||||
)
|
||||
|
||||
expect(getInputData().sourceRef.filename).toBe('real-output.png')
|
||||
})
|
||||
|
||||
it('loads image references from node output store data', async () => {
|
||||
mockNodeOutputStore.getNodeOutputs.mockReturnValue({
|
||||
images: [
|
||||
{
|
||||
filename: 'store-output.png',
|
||||
subfolder: 'store',
|
||||
type: 'temp'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
await useMaskEditorLoader().loadFromNode(
|
||||
makeNode({
|
||||
images: undefined,
|
||||
imgs: [{ src: 'data:image/png;base64,abc' }]
|
||||
})
|
||||
)
|
||||
|
||||
expect(getInputData().sourceRef).toMatchObject({
|
||||
filename: 'store-output.png',
|
||||
subfolder: 'store',
|
||||
type: 'temp'
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the current non-data preview image when no image reference exists', async () => {
|
||||
await useMaskEditorLoader().loadFromNode(
|
||||
makeNode({
|
||||
images: undefined,
|
||||
imageIndex: 1,
|
||||
imgs: [
|
||||
{ src: 'http://images.test/first.png?filename=first.png' },
|
||||
{ src: '/view?filename=second.png&type=input' }
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
expect(getInputData().sourceRef).toMatchObject({
|
||||
filename: 'second.png',
|
||||
type: 'input'
|
||||
})
|
||||
})
|
||||
|
||||
it('uses cloud mask layer metadata when available', async () => {
|
||||
mockCloudState.isCloud = true
|
||||
vi.mocked(api.fetchApi).mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
painted_masked: 'painted-masked.png',
|
||||
painted: 'painted.png',
|
||||
paint: 'paint.png'
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
await useMaskEditorLoader().loadFromNode(
|
||||
makeNode({
|
||||
images: [{ filename: 'cloud.png', type: 'output' }]
|
||||
})
|
||||
)
|
||||
|
||||
expect(api.fetchApi).toHaveBeenCalledWith(
|
||||
'/files/mask-layers?filename=cloud.png'
|
||||
)
|
||||
expect(getInputData().sourceRef.filename).toBe('painted-masked.png')
|
||||
expect(getInputData().paintLayer?.url).toContain('filename=paint.png')
|
||||
})
|
||||
|
||||
it('loads clipspace layer filenames from painted-masked images', async () => {
|
||||
await useMaskEditorLoader().loadFromNode(
|
||||
makeNode({
|
||||
images: [
|
||||
{
|
||||
filename: 'clipspace-painted-masked-123.png',
|
||||
subfolder: 'clipspace',
|
||||
type: 'input'
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
expect(getInputData().sourceRef).toMatchObject({
|
||||
filename: 'clipspace-mask-123.png',
|
||||
subfolder: 'clipspace',
|
||||
type: 'input'
|
||||
})
|
||||
expect(getInputData().paintLayer?.url).toContain(
|
||||
'filename=clipspace-paint-123.png'
|
||||
)
|
||||
})
|
||||
|
||||
it('uses painted cloud metadata when painted-masked metadata is absent', async () => {
|
||||
mockCloudState.isCloud = true
|
||||
vi.mocked(api.fetchApi).mockResolvedValue(
|
||||
new Response(JSON.stringify({ painted: 'painted-only.png' }))
|
||||
)
|
||||
|
||||
await useMaskEditorLoader().loadFromNode(
|
||||
makeNode({
|
||||
images: [{ filename: 'cloud.png', type: 'output' }]
|
||||
})
|
||||
)
|
||||
|
||||
expect(getInputData().sourceRef.filename).toBe('painted-only.png')
|
||||
expect(getInputData().paintLayer).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps the node image when cloud mask metadata is unavailable', async () => {
|
||||
mockCloudState.isCloud = true
|
||||
vi.mocked(api.fetchApi).mockResolvedValue({
|
||||
ok: false
|
||||
} as Response)
|
||||
|
||||
await useMaskEditorLoader().loadFromNode(
|
||||
makeNode({
|
||||
images: [{ filename: 'cloud.png', type: 'output' }]
|
||||
})
|
||||
)
|
||||
|
||||
expect(getInputData().sourceRef.filename).toBe('cloud.png')
|
||||
expect(getInputData().paintLayer).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps the node image when cloud mask metadata lookup rejects', async () => {
|
||||
mockCloudState.isCloud = true
|
||||
vi.mocked(api.fetchApi).mockRejectedValue(new Error('offline'))
|
||||
|
||||
await useMaskEditorLoader().loadFromNode(
|
||||
makeNode({
|
||||
images: [{ filename: 'cloud.png', type: 'output' }]
|
||||
})
|
||||
)
|
||||
|
||||
expect(getInputData().sourceRef.filename).toBe('cloud.png')
|
||||
})
|
||||
|
||||
it('loads widget filenames without explicit folder metadata as inputs', async () => {
|
||||
await useMaskEditorLoader().loadFromNode(
|
||||
makeNode({
|
||||
images: [{ filename: 'stale.png', type: 'output' }],
|
||||
widgets: [
|
||||
{
|
||||
name: 'image',
|
||||
value: 'plain.png'
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
expect(getInputData().sourceRef).toMatchObject({
|
||||
filename: 'plain.png',
|
||||
type: 'input'
|
||||
})
|
||||
expect(getInputData().sourceRef.subfolder).toBeUndefined()
|
||||
})
|
||||
|
||||
it('surfaces validation failures and clears loading state', async () => {
|
||||
await expect(
|
||||
useMaskEditorLoader().loadFromNode(makeNode({ imgs: [], images: [] }))
|
||||
).rejects.toThrow('Node has no images')
|
||||
|
||||
expect(mockDataStore.setLoading).toHaveBeenNthCalledWith(1, true)
|
||||
expect(mockDataStore.setLoading).toHaveBeenLastCalledWith(
|
||||
false,
|
||||
'Node has no images'
|
||||
)
|
||||
})
|
||||
|
||||
it('surfaces null node validation failures', async () => {
|
||||
const nullNode: unknown = null
|
||||
await expect(
|
||||
useMaskEditorLoader().loadFromNode(nullNode as LGraphNode)
|
||||
).rejects.toThrow('Node is null or undefined')
|
||||
})
|
||||
|
||||
it('surfaces missing output filenames', async () => {
|
||||
mockNodeOutputStore.getNodeOutputs.mockReturnValue({
|
||||
images: [
|
||||
{
|
||||
filename: '',
|
||||
type: 'output'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
await expect(
|
||||
useMaskEditorLoader().loadFromNode(
|
||||
makeNode({
|
||||
images: undefined,
|
||||
imgs: [{ src: 'data:image/png;base64,abc' }]
|
||||
})
|
||||
)
|
||||
).rejects.toThrow('nodeOutputStore image missing filename')
|
||||
})
|
||||
|
||||
it('rejects data previews without output metadata', async () => {
|
||||
await expect(
|
||||
useMaskEditorLoader().loadFromNode(
|
||||
makeNode({
|
||||
images: undefined,
|
||||
imgs: [{ src: 'data:image/png;base64,abc' }]
|
||||
})
|
||||
)
|
||||
).rejects.toThrow('Unable to get image URL from node')
|
||||
})
|
||||
|
||||
it('rejects preview URLs without filename metadata', async () => {
|
||||
await expect(
|
||||
useMaskEditorLoader().loadFromNode(
|
||||
makeNode({
|
||||
images: undefined,
|
||||
imgs: [{ src: '/view?type=input' }]
|
||||
})
|
||||
)
|
||||
).rejects.toThrow('Invalid image URL: /view?type=input')
|
||||
})
|
||||
|
||||
it('propagates image load failures', async () => {
|
||||
vi.stubGlobal('Image', createMockImageClass('onerror'))
|
||||
|
||||
await expect(
|
||||
useMaskEditorLoader().loadFromNode(
|
||||
makeNode({
|
||||
images: [{ filename: 'broken.png', type: 'output' }]
|
||||
})
|
||||
)
|
||||
).rejects.toThrow('Failed to load image:')
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,9 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
@@ -98,7 +97,7 @@ describe('useMaskEditorSaver', () => {
|
||||
app.nodeOutputs = {}
|
||||
app.nodePreviewImages = {}
|
||||
|
||||
const nodeObj: unknown = {
|
||||
mockNode = fromAny<LGraphNode, unknown>({
|
||||
id: 42,
|
||||
type: 'LoadImage',
|
||||
images: [],
|
||||
@@ -109,8 +108,7 @@ describe('useMaskEditorSaver', () => {
|
||||
widgets_values: ['original.png [input]'],
|
||||
properties: { image: 'original.png [input]' },
|
||||
graph: { setDirtyCanvas: vi.fn() }
|
||||
}
|
||||
mockNode = nodeObj as LGraphNode
|
||||
})
|
||||
|
||||
mockDataStore.sourceNode = mockNode
|
||||
mockDataStore.inputData = {
|
||||
@@ -137,7 +135,8 @@ describe('useMaskEditorSaver', () => {
|
||||
|
||||
vi.spyOn(document, 'createElement').mockImplementation(
|
||||
(tagName: string, options?: ElementCreationOptions) => {
|
||||
if (tagName === 'canvas') return createMockCanvas()
|
||||
if (tagName === 'canvas')
|
||||
return fromAny<HTMLCanvasElement, unknown>(createMockCanvas())
|
||||
return originalCreateElement(tagName, options)
|
||||
}
|
||||
)
|
||||
@@ -202,112 +201,4 @@ describe('useMaskEditorSaver', () => {
|
||||
expect(body.get('type')).toBe('input')
|
||||
expect(body.get('subfolder')).toBeNull()
|
||||
})
|
||||
|
||||
it('throws before saving when the source node is missing', async () => {
|
||||
mockDataStore.sourceNode = null
|
||||
|
||||
const { save } = useMaskEditorSaver()
|
||||
|
||||
await expect(save()).rejects.toThrow('No source node or input data')
|
||||
expect(api.fetchApi).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('throws before saving when the input data is missing', async () => {
|
||||
mockDataStore.inputData = null
|
||||
|
||||
const { save } = useMaskEditorSaver()
|
||||
|
||||
await expect(save()).rejects.toThrow('No source node or input data')
|
||||
expect(api.fetchApi).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails when canvases are not initialized', async () => {
|
||||
mockEditorStore.maskCanvas = null
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
const { save } = useMaskEditorSaver()
|
||||
|
||||
await expect(save()).rejects.toThrow('Canvas not initialized')
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
'[MaskEditorSaver] Save failed:',
|
||||
expect.any(Error)
|
||||
)
|
||||
})
|
||||
|
||||
it('reports upload failures with the response body', async () => {
|
||||
vi.mocked(api.fetchApi).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 413,
|
||||
text: () => Promise.resolve('too large')
|
||||
} as Response)
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
const { save } = useMaskEditorSaver()
|
||||
|
||||
await expect(save()).rejects.toThrow(
|
||||
/Failed to upload clipspace-mask-.*: too large/
|
||||
)
|
||||
})
|
||||
|
||||
it('reports upload failures when the response body cannot be read', async () => {
|
||||
vi.mocked(api.fetchApi).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: () => Promise.reject(new Error('body unavailable'))
|
||||
} as Response)
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
const { save } = useMaskEditorSaver()
|
||||
|
||||
await expect(save()).rejects.toThrow(/Failed to upload .+ \(500\)/)
|
||||
})
|
||||
|
||||
it('reports invalid upload JSON responses', async () => {
|
||||
vi.mocked(api.fetchApi).mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.reject(new Error('bad json'))
|
||||
} as Response)
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
const { save } = useMaskEditorSaver()
|
||||
|
||||
await expect(save()).rejects.toThrow(/Invalid upload response.*bad json/)
|
||||
})
|
||||
|
||||
it('reports upload responses without a name', async () => {
|
||||
vi.mocked(api.fetchApi).mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ subfolder: 'clipspace', type: 'input' })
|
||||
} as Response)
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
const { save } = useMaskEditorSaver()
|
||||
|
||||
await expect(save()).rejects.toThrow(
|
||||
"Upload response missing 'name' for clipspace-mask-"
|
||||
)
|
||||
})
|
||||
|
||||
it('defaults missing upload ref fields and skips missing image widget state', async () => {
|
||||
mockNode.widgets = [
|
||||
fromPartial<IBaseWidget>({ name: 'other', value: 'unchanged' })
|
||||
]
|
||||
mockNode.widgets_values = ['unchanged']
|
||||
const noProperties: unknown = undefined
|
||||
const noGraph: unknown = undefined
|
||||
mockNode.properties = noProperties as LGraphNode['properties']
|
||||
mockNode.graph = noGraph as LGraphNode['graph']
|
||||
vi.mocked(api.fetchApi).mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ name: 'uploaded.png' })
|
||||
} as Response)
|
||||
|
||||
const { save } = useMaskEditorSaver()
|
||||
await save()
|
||||
|
||||
expect(mockNode.images).toEqual([
|
||||
{ filename: 'uploaded.png', subfolder: '', type: 'input' }
|
||||
])
|
||||
expect(mockNode.widgets_values).toEqual(['unchanged'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -41,7 +40,7 @@ vi.mock('@/stores/maskEditorStore', () => ({
|
||||
}))
|
||||
|
||||
function createMockElement(width = 1200, height = 800): HTMLElement {
|
||||
return fromPartial<HTMLElement>({
|
||||
return {
|
||||
clientWidth: width,
|
||||
clientHeight: height,
|
||||
style: {} as CSSStyleDeclaration,
|
||||
@@ -54,11 +53,11 @@ function createMockElement(width = 1200, height = 800): HTMLElement {
|
||||
right: width,
|
||||
bottom: height
|
||||
}) as DOMRect
|
||||
})
|
||||
} as unknown as HTMLElement
|
||||
}
|
||||
|
||||
function createMockCanvas(width: number, height: number): HTMLCanvasElement {
|
||||
return fromPartial<HTMLCanvasElement>({
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
clientWidth: width,
|
||||
@@ -73,7 +72,7 @@ function createMockCanvas(width: number, height: number): HTMLCanvasElement {
|
||||
right: width,
|
||||
bottom: height
|
||||
}) as DOMRect
|
||||
})
|
||||
} as unknown as HTMLCanvasElement
|
||||
}
|
||||
|
||||
function createMockImage(width: number, height: number): HTMLImageElement {
|
||||
@@ -82,15 +81,17 @@ function createMockImage(width: number, height: number): HTMLImageElement {
|
||||
|
||||
function createTouchList(...points: { x: number; y: number }[]): TouchList {
|
||||
const touches = points.map((p) => ({ clientX: p.x, clientY: p.y }) as Touch)
|
||||
const obj: unknown = Object.assign(touches, {
|
||||
return Object.assign(touches, {
|
||||
length: touches.length,
|
||||
item: (i: number) => touches[i]
|
||||
})
|
||||
return obj as TouchList
|
||||
}) as unknown as TouchList
|
||||
}
|
||||
|
||||
function createTouchEvent(touches: TouchList): TouchEvent {
|
||||
return fromPartial<TouchEvent>({ touches, preventDefault: vi.fn() })
|
||||
return {
|
||||
touches,
|
||||
preventDefault: vi.fn()
|
||||
} as unknown as TouchEvent
|
||||
}
|
||||
|
||||
async function initComposable() {
|
||||
@@ -99,7 +100,7 @@ async function initComposable() {
|
||||
const root = createMockElement()
|
||||
const container = createMockElement()
|
||||
const canvas = createMockCanvas(800, 600)
|
||||
mockStore.canvasContainer = container
|
||||
mockStore.canvasContainer = container as unknown as HTMLElement
|
||||
mockStore.maskCanvas = canvas
|
||||
await pz.initializeCanvasPanZoom(img, root)
|
||||
vi.clearAllMocks()
|
||||
@@ -128,7 +129,7 @@ describe('usePanAndZoom', () => {
|
||||
it('sets zoom and pan on the store', async () => {
|
||||
const pz = usePanAndZoom()
|
||||
const container = createMockElement()
|
||||
mockStore.canvasContainer = container
|
||||
mockStore.canvasContainer = container as unknown as HTMLElement
|
||||
|
||||
await pz.initializeCanvasPanZoom(
|
||||
createMockImage(800, 600),
|
||||
@@ -144,7 +145,7 @@ describe('usePanAndZoom', () => {
|
||||
|
||||
it('accounts for panel widths via setPanOffset', async () => {
|
||||
const pz = usePanAndZoom()
|
||||
mockStore.canvasContainer = createMockElement()
|
||||
mockStore.canvasContainer = createMockElement() as unknown as HTMLElement
|
||||
|
||||
const toolPanel = createMockElement()
|
||||
vi.spyOn(toolPanel, 'getBoundingClientRect').mockReturnValue({
|
||||
@@ -169,7 +170,7 @@ describe('usePanAndZoom', () => {
|
||||
it('syncs rgbCanvas dimensions when they differ', async () => {
|
||||
const pz = usePanAndZoom()
|
||||
const rgbCanvas = createMockCanvas(400, 300)
|
||||
mockStore.canvasContainer = createMockElement()
|
||||
mockStore.canvasContainer = createMockElement() as unknown as HTMLElement
|
||||
mockStore.rgbCanvas = rgbCanvas
|
||||
|
||||
await pz.initializeCanvasPanZoom(
|
||||
@@ -180,50 +181,6 @@ describe('usePanAndZoom', () => {
|
||||
expect(rgbCanvas.width).toBe(800)
|
||||
expect(rgbCanvas.height).toBe(600)
|
||||
})
|
||||
|
||||
it('returns before publishing pan when the canvas container is unavailable', async () => {
|
||||
const pz = usePanAndZoom()
|
||||
|
||||
await pz.initializeCanvasPanZoom(
|
||||
createMockImage(800, 600),
|
||||
createMockElement()
|
||||
)
|
||||
|
||||
expect(mockStore.setPanOffset).not.toHaveBeenCalled()
|
||||
expect(mockStore.setZoomRatio).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps rgbCanvas dimensions when they already match the image', async () => {
|
||||
const pz = usePanAndZoom()
|
||||
const rgbCanvas = createMockCanvas(800, 600)
|
||||
mockStore.canvasContainer = createMockElement()
|
||||
mockStore.rgbCanvas = rgbCanvas
|
||||
|
||||
await pz.initializeCanvasPanZoom(
|
||||
createMockImage(800, 600),
|
||||
createMockElement()
|
||||
)
|
||||
|
||||
expect(rgbCanvas.width).toBe(800)
|
||||
expect(rgbCanvas.height).toBe(600)
|
||||
expect(rgbCanvas.style.width).not.toBe('')
|
||||
})
|
||||
|
||||
it('can be initialized again without replacing the current image reference', async () => {
|
||||
const pz = usePanAndZoom()
|
||||
mockStore.canvasContainer = createMockElement()
|
||||
|
||||
await pz.initializeCanvasPanZoom(
|
||||
createMockImage(800, 600),
|
||||
createMockElement()
|
||||
)
|
||||
await pz.initializeCanvasPanZoom(
|
||||
createMockImage(400, 300),
|
||||
createMockElement()
|
||||
)
|
||||
|
||||
expect(mockStore.setZoomRatio).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handlePanStart / handlePanMove', () => {
|
||||
@@ -323,7 +280,7 @@ describe('usePanAndZoom', () => {
|
||||
it('returns early when maskCanvas is null', async () => {
|
||||
const pz = usePanAndZoom()
|
||||
const container = createMockElement()
|
||||
mockStore.canvasContainer = container
|
||||
mockStore.canvasContainer = container as unknown as HTMLElement
|
||||
mockStore.maskCanvas = null
|
||||
await pz.initializeCanvasPanZoom(
|
||||
createMockImage(800, 600),
|
||||
@@ -388,13 +345,6 @@ describe('usePanAndZoom', () => {
|
||||
expect(mockStore.brushVisible).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts touch starts without active touches', () => {
|
||||
const pz = usePanAndZoom()
|
||||
pz.handleTouchStart(createTouchEvent(createTouchList()))
|
||||
expect(mockStore.brushVisible).toBe(false)
|
||||
expect(mockStore.canvasHistory.undo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores touch when pen pointer is active', () => {
|
||||
const pz = usePanAndZoom()
|
||||
pz.addPenPointerId(1)
|
||||
@@ -451,49 +401,6 @@ describe('usePanAndZoom', () => {
|
||||
expect(mockStore.setZoomRatio).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns from pinch zoom when the mask canvas is unavailable', async () => {
|
||||
const pz = usePanAndZoom()
|
||||
mockStore.maskCanvas = null
|
||||
|
||||
pz.handleTouchStart(
|
||||
createTouchEvent(
|
||||
createTouchList({ x: 200, y: 300 }, { x: 400, y: 300 })
|
||||
)
|
||||
)
|
||||
|
||||
await pz.handleTouchMove(
|
||||
createTouchEvent(
|
||||
createTouchList({ x: 150, y: 300 }, { x: 450, y: 300 })
|
||||
)
|
||||
)
|
||||
|
||||
expect(mockStore.setZoomRatio).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses the cached mask canvas for consecutive pinch moves', async () => {
|
||||
const { pz } = await initComposable()
|
||||
|
||||
pz.handleTouchStart(
|
||||
createTouchEvent(
|
||||
createTouchList({ x: 200, y: 300 }, { x: 400, y: 300 })
|
||||
)
|
||||
)
|
||||
await pz.handleTouchMove(
|
||||
createTouchEvent(
|
||||
createTouchList({ x: 150, y: 300 }, { x: 450, y: 300 })
|
||||
)
|
||||
)
|
||||
vi.clearAllMocks()
|
||||
|
||||
await pz.handleTouchMove(
|
||||
createTouchEvent(
|
||||
createTouchList({ x: 140, y: 300 }, { x: 460, y: 300 })
|
||||
)
|
||||
)
|
||||
|
||||
expect(mockStore.setZoomRatio).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('touch move is ignored when pen is active', async () => {
|
||||
const pz = usePanAndZoom()
|
||||
pz.addPenPointerId(1)
|
||||
@@ -511,26 +418,6 @@ describe('usePanAndZoom', () => {
|
||||
pz.handleTouchEnd(event)
|
||||
expect(event.preventDefault).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stops pinch zooming when all touches end', async () => {
|
||||
const { pz } = await initComposable()
|
||||
|
||||
pz.handleTouchStart(
|
||||
createTouchEvent(
|
||||
createTouchList({ x: 200, y: 300 }, { x: 400, y: 300 })
|
||||
)
|
||||
)
|
||||
pz.handleTouchEnd(createTouchEvent(createTouchList()))
|
||||
vi.clearAllMocks()
|
||||
|
||||
await pz.handleTouchMove(
|
||||
createTouchEvent(
|
||||
createTouchList({ x: 150, y: 300 }, { x: 450, y: 300 })
|
||||
)
|
||||
)
|
||||
|
||||
expect(mockStore.setZoomRatio).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('pen pointer management', () => {
|
||||
|
||||
@@ -73,46 +73,4 @@ describe('useNodeAnimatedImage', () => {
|
||||
expect(canvasInteractionsMock.handlePointerDown).not.toHaveBeenCalled()
|
||||
expect(canvasInteractionsMock.forwardEventToCanvas).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does nothing when a node has no images or widgets', () => {
|
||||
const { showAnimatedPreview, removeAnimatedPreview } =
|
||||
useNodeAnimatedImage()
|
||||
const noImageNode = createMockMediaNode({ imgs: [] })
|
||||
const noWidgetNode = Object.assign(
|
||||
createMockMediaNode({ imgs: [document.createElement('img')] }),
|
||||
{ widgets: undefined }
|
||||
)
|
||||
|
||||
showAnimatedPreview(noImageNode)
|
||||
showAnimatedPreview(noWidgetNode)
|
||||
removeAnimatedPreview(noWidgetNode)
|
||||
|
||||
expect(noImageNode.widgets).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('replaces the image in an existing preview widget', () => {
|
||||
const { node, showAnimatedPreview } = setup()
|
||||
const firstWidget = node.widgets[0]
|
||||
const nextImage = document.createElement('img')
|
||||
node.imgs = [nextImage]
|
||||
|
||||
showAnimatedPreview(node)
|
||||
|
||||
expect(node.widgets).toHaveLength(1)
|
||||
expect(node.widgets[0]).toBe(firstWidget)
|
||||
expect(firstWidget.element.firstChild).toBe(nextImage)
|
||||
})
|
||||
|
||||
it('leaves an existing non-DOM preview widget untouched', () => {
|
||||
const node = createMockMediaNode({ imgs: [document.createElement('img')] })
|
||||
const noElement: unknown = undefined
|
||||
node.widgets.push({
|
||||
name: '$$comfy_animation_preview',
|
||||
element: noElement as HTMLElement
|
||||
})
|
||||
|
||||
useNodeAnimatedImage().showAnimatedPreview(node)
|
||||
|
||||
expect(node.widgets).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,431 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, defineComponent, h, nextTick } from 'vue'
|
||||
import type { App as VueApp } from 'vue'
|
||||
|
||||
import { useNodeBadge } from '@/composables/node/useNodeBadge'
|
||||
import { BadgePosition, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphBadge } from '@/lib/litegraph/src/litegraph'
|
||||
import type { ComfyExtension } from '@/types/comfy'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import { NodeBadgeMode } from '@/types/nodeSource'
|
||||
|
||||
const {
|
||||
settings,
|
||||
appState,
|
||||
extensionState,
|
||||
nodeDefState,
|
||||
pricingState,
|
||||
setDirtyMock,
|
||||
addEventListenerMock,
|
||||
registerExtensionMock,
|
||||
getCreditsBadgeMock,
|
||||
updateSubgraphCreditsMock,
|
||||
getNodePricingConfigMock,
|
||||
getNodeDisplayPriceMock,
|
||||
getRelevantWidgetNamesMock,
|
||||
triggerPriceRecalculationMock,
|
||||
useComputedWithWidgetWatchMock
|
||||
} = vi.hoisted(() => ({
|
||||
settings: {} as Record<string, unknown>,
|
||||
appState: {
|
||||
graph: {
|
||||
nodes: [] as unknown[]
|
||||
}
|
||||
},
|
||||
extensionState: {
|
||||
installed: false,
|
||||
registered: undefined as ComfyExtension | undefined
|
||||
},
|
||||
nodeDefState: {
|
||||
value: null as Record<string, unknown> | null
|
||||
},
|
||||
pricingState: {
|
||||
revision: { value: 0 },
|
||||
config: undefined as
|
||||
| {
|
||||
depends_on?: {
|
||||
widgets?: string[]
|
||||
inputs?: string[]
|
||||
input_groups?: string[]
|
||||
}
|
||||
}
|
||||
| undefined,
|
||||
label: '1 credit'
|
||||
},
|
||||
setDirtyMock: vi.fn(),
|
||||
addEventListenerMock: vi.fn(),
|
||||
registerExtensionMock: vi.fn((extension: ComfyExtension) => {
|
||||
extensionState.registered = extension
|
||||
}),
|
||||
getCreditsBadgeMock: vi.fn((text: string) => ({ text })),
|
||||
updateSubgraphCreditsMock: vi.fn(),
|
||||
getNodePricingConfigMock: vi.fn(() => pricingState.config),
|
||||
getNodeDisplayPriceMock: vi.fn(() => pricingState.label),
|
||||
getRelevantWidgetNamesMock: vi.fn(() => ['seed']),
|
||||
triggerPriceRecalculationMock: vi.fn(),
|
||||
useComputedWithWidgetWatchMock: vi.fn(() => vi.fn())
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
canvas: {
|
||||
setDirty: setDirtyMock,
|
||||
canvas: {
|
||||
addEventListener: addEventListenerMock
|
||||
},
|
||||
graph: appState.graph
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: (key: string) => settings[key]
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/extensionStore', () => ({
|
||||
useExtensionStore: () => ({
|
||||
isExtensionInstalled: () => extensionState.installed,
|
||||
registerExtension: registerExtensionMock
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/nodeDefStore', () => ({
|
||||
useNodeDefStore: () => ({
|
||||
fromLGraphNode: () => nodeDefState.value
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workspace/colorPaletteStore', () => ({
|
||||
useColorPaletteStore: () => ({
|
||||
completedActivePalette: {
|
||||
colors: {
|
||||
litegraph_base: {
|
||||
BADGE_FG_COLOR: '#fff',
|
||||
BADGE_BG_COLOR: '#000'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/node/useNodePricing', async () => {
|
||||
const { ref } = await import('vue')
|
||||
const pricingRevision = ref(pricingState.revision.value)
|
||||
Object.defineProperty(pricingState.revision, 'value', {
|
||||
get: () => pricingRevision.value,
|
||||
set: (value: number) => {
|
||||
pricingRevision.value = value
|
||||
}
|
||||
})
|
||||
return {
|
||||
useNodePricing: () => ({
|
||||
pricingRevision,
|
||||
getNodePricingConfig: getNodePricingConfigMock,
|
||||
getNodeDisplayPrice: getNodeDisplayPriceMock,
|
||||
getRelevantWidgetNames: getRelevantWidgetNamesMock,
|
||||
triggerPriceRecalculation: triggerPriceRecalculationMock
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/composables/node/usePriceBadge', () => ({
|
||||
usePriceBadge: () => ({
|
||||
getCreditsBadge: getCreditsBadgeMock,
|
||||
updateSubgraphCredits: updateSubgraphCreditsMock
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/node/useWatchWidget', () => ({
|
||||
useComputedWithWidgetWatch: useComputedWithWidgetWatchMock
|
||||
}))
|
||||
|
||||
class ApiNode extends LGraphNode {
|
||||
static override nodeData = { name: 'ApiNode', api_node: true }
|
||||
}
|
||||
|
||||
function mountBadge(): VueApp {
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup() {
|
||||
useNodeBadge()
|
||||
return () => h('div')
|
||||
}
|
||||
})
|
||||
)
|
||||
app.mount(document.createElement('div'))
|
||||
return app
|
||||
}
|
||||
|
||||
function registeredExtension(): ComfyExtension {
|
||||
if (!extensionState.registered)
|
||||
throw new Error('Missing registered extension')
|
||||
return extensionState.registered
|
||||
}
|
||||
|
||||
function comfyApp(): Parameters<NonNullable<ComfyExtension['init']>>[0] {
|
||||
return {} as Parameters<NonNullable<ComfyExtension['init']>>[0]
|
||||
}
|
||||
|
||||
function callNodeCreated(node: LGraphNode) {
|
||||
registeredExtension().nodeCreated?.(node, comfyApp())
|
||||
}
|
||||
|
||||
function inputSlot(name: string) {
|
||||
return new LGraphNode('slot').addInput(name, '*')
|
||||
}
|
||||
|
||||
function defaultSettings() {
|
||||
settings['Comfy.NodeBadge.NodeSourceBadgeMode'] = NodeBadgeMode.None
|
||||
settings['Comfy.NodeBadge.NodeIdBadgeMode'] = NodeBadgeMode.None
|
||||
settings['Comfy.NodeBadge.NodeLifeCycleBadgeMode'] = NodeBadgeMode.None
|
||||
settings['Comfy.NodeBadge.ShowApiPricing'] = false
|
||||
}
|
||||
|
||||
describe('useNodeBadge', () => {
|
||||
let mountedApp: VueApp | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
defaultSettings()
|
||||
extensionState.installed = false
|
||||
extensionState.registered = undefined
|
||||
appState.graph.nodes = []
|
||||
nodeDefState.value = null
|
||||
pricingState.revision.value = 0
|
||||
pricingState.config = undefined
|
||||
pricingState.label = '1 credit'
|
||||
setDirtyMock.mockClear()
|
||||
addEventListenerMock.mockClear()
|
||||
registerExtensionMock.mockClear()
|
||||
getCreditsBadgeMock.mockClear()
|
||||
updateSubgraphCreditsMock.mockClear()
|
||||
getNodePricingConfigMock.mockClear()
|
||||
getNodeDisplayPriceMock.mockClear()
|
||||
getRelevantWidgetNamesMock.mockClear()
|
||||
triggerPriceRecalculationMock.mockClear()
|
||||
useComputedWithWidgetWatchMock.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mountedApp?.unmount()
|
||||
mountedApp = undefined
|
||||
})
|
||||
|
||||
it('does not register the badge extension twice', async () => {
|
||||
extensionState.installed = true
|
||||
mountedApp = mountBadge()
|
||||
await nextTick()
|
||||
|
||||
expect(registerExtensionMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('adds the configured node identity badge', async () => {
|
||||
settings['Comfy.NodeBadge.NodeSourceBadgeMode'] = NodeBadgeMode.ShowAll
|
||||
settings['Comfy.NodeBadge.NodeIdBadgeMode'] = NodeBadgeMode.ShowAll
|
||||
settings['Comfy.NodeBadge.NodeLifeCycleBadgeMode'] =
|
||||
NodeBadgeMode.HideBuiltIn
|
||||
nodeDefState.value = {
|
||||
isCoreNode: false,
|
||||
nodeLifeCycleBadgeText: 'Beta',
|
||||
nodeSource: { badgeText: 'Pack' }
|
||||
}
|
||||
const node = new LGraphNode('Test')
|
||||
node.id = toNodeId('7')
|
||||
|
||||
mountedApp = mountBadge()
|
||||
await nextTick()
|
||||
callNodeCreated(node)
|
||||
const badge = node.badges[0] as () => LGraphBadge
|
||||
|
||||
expect(node.badgePosition).toBe(BadgePosition.TopRight)
|
||||
expect(badge().text).toBe('#7 Beta Pack')
|
||||
})
|
||||
|
||||
it('hides built-in badge text when the mode excludes core nodes', async () => {
|
||||
settings['Comfy.NodeBadge.NodeSourceBadgeMode'] = NodeBadgeMode.HideBuiltIn
|
||||
settings['Comfy.NodeBadge.NodeIdBadgeMode'] = NodeBadgeMode.ShowAll
|
||||
settings['Comfy.NodeBadge.NodeLifeCycleBadgeMode'] =
|
||||
NodeBadgeMode.HideBuiltIn
|
||||
nodeDefState.value = {
|
||||
isCoreNode: true,
|
||||
nodeLifeCycleBadgeText: 'Core',
|
||||
nodeSource: { badgeText: 'Built-in' }
|
||||
}
|
||||
const node = new LGraphNode('Core')
|
||||
node.id = toNodeId('11')
|
||||
|
||||
mountedApp = mountBadge()
|
||||
await nextTick()
|
||||
callNodeCreated(node)
|
||||
const badge = node.badges[0] as () => LGraphBadge
|
||||
|
||||
expect(badge().text).toBe('#11')
|
||||
})
|
||||
|
||||
it('keeps optional node definition badge text empty', async () => {
|
||||
settings['Comfy.NodeBadge.NodeSourceBadgeMode'] = NodeBadgeMode.ShowAll
|
||||
settings['Comfy.NodeBadge.NodeIdBadgeMode'] = NodeBadgeMode.ShowAll
|
||||
settings['Comfy.NodeBadge.NodeLifeCycleBadgeMode'] = NodeBadgeMode.ShowAll
|
||||
nodeDefState.value = null
|
||||
const node = new LGraphNode('NoDef')
|
||||
node.id = toNodeId('13')
|
||||
|
||||
mountedApp = mountBadge()
|
||||
await nextTick()
|
||||
callNodeCreated(node)
|
||||
const badge = node.badges[0] as () => LGraphBadge
|
||||
|
||||
expect(badge().text).toBe('#13')
|
||||
})
|
||||
|
||||
it('marks the canvas dirty when pricing changes while pricing badges are visible', async () => {
|
||||
settings['Comfy.NodeBadge.ShowApiPricing'] = true
|
||||
mountedApp = mountBadge()
|
||||
await nextTick()
|
||||
|
||||
pricingState.revision.value++
|
||||
await nextTick()
|
||||
|
||||
expect(setDirtyMock).toHaveBeenCalledWith(true, true)
|
||||
})
|
||||
|
||||
it('does not add API pricing badges when the pricing setting is disabled', async () => {
|
||||
settings['Comfy.NodeBadge.ShowApiPricing'] = false
|
||||
const node = new ApiNode('API')
|
||||
|
||||
mountedApp = mountBadge()
|
||||
await nextTick()
|
||||
callNodeCreated(node)
|
||||
|
||||
expect(node.badges).toHaveLength(1)
|
||||
expect(getCreditsBadgeMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('adds static API pricing badges without widget watchers', async () => {
|
||||
settings['Comfy.NodeBadge.ShowApiPricing'] = true
|
||||
pricingState.config = undefined
|
||||
const node = new ApiNode('API')
|
||||
|
||||
mountedApp = mountBadge()
|
||||
await nextTick()
|
||||
callNodeCreated(node)
|
||||
|
||||
expect(node.badges).toHaveLength(2)
|
||||
expect(useComputedWithWidgetWatchMock).not.toHaveBeenCalled()
|
||||
expect(getCreditsBadgeMock).toHaveBeenCalledWith('1 credit')
|
||||
})
|
||||
|
||||
it('adds dynamic widget pricing without connection hooks when no inputs matter', async () => {
|
||||
settings['Comfy.NodeBadge.ShowApiPricing'] = true
|
||||
pricingState.config = {
|
||||
depends_on: {
|
||||
widgets: ['seed']
|
||||
}
|
||||
}
|
||||
const node = new ApiNode('API')
|
||||
const originalOnConnectionsChange = node.onConnectionsChange
|
||||
|
||||
mountedApp = mountBadge()
|
||||
await nextTick()
|
||||
callNodeCreated(node)
|
||||
|
||||
expect(useComputedWithWidgetWatchMock).toHaveBeenCalled()
|
||||
expect(node.onConnectionsChange).toBe(originalOnConnectionsChange)
|
||||
})
|
||||
|
||||
it('adds dynamic API pricing badges and refreshes relevant input changes', async () => {
|
||||
settings['Comfy.NodeBadge.ShowApiPricing'] = true
|
||||
pricingState.config = {
|
||||
depends_on: {
|
||||
widgets: ['seed'],
|
||||
inputs: ['image'],
|
||||
input_groups: ['lora']
|
||||
}
|
||||
}
|
||||
const originalOnConnectionsChange = vi.fn()
|
||||
const node = new ApiNode('API')
|
||||
node.onConnectionsChange = originalOnConnectionsChange
|
||||
|
||||
mountedApp = mountBadge()
|
||||
await nextTick()
|
||||
callNodeCreated(node)
|
||||
|
||||
expect(useComputedWithWidgetWatchMock).toHaveBeenCalledWith(node, {
|
||||
widgetNames: ['seed'],
|
||||
triggerCanvasRedraw: true
|
||||
})
|
||||
expect(getCreditsBadgeMock).toHaveBeenCalledWith('1 credit')
|
||||
|
||||
const priceBadge = node.badges[1] as () => { text: string }
|
||||
expect(priceBadge().text).toBe('1 credit')
|
||||
pricingState.label = '2 credits'
|
||||
expect(priceBadge().text).toBe('2 credits')
|
||||
|
||||
node.onConnectionsChange?.(1, 0, true, undefined, inputSlot('image'))
|
||||
node.onConnectionsChange?.(1, 0, true, undefined, inputSlot('lora.0'))
|
||||
node.onConnectionsChange?.(1, 0, true, undefined, inputSlot('clip'))
|
||||
node.onConnectionsChange?.(1, 0, true, undefined, inputSlot(''))
|
||||
|
||||
expect(originalOnConnectionsChange).toHaveBeenCalledTimes(4)
|
||||
expect(triggerPriceRecalculationMock).toHaveBeenCalledTimes(2)
|
||||
expect(triggerPriceRecalculationMock).toHaveBeenCalledWith(node)
|
||||
})
|
||||
|
||||
it('refreshes dynamic pricing inputs without an existing connection hook', async () => {
|
||||
settings['Comfy.NodeBadge.ShowApiPricing'] = true
|
||||
pricingState.config = {
|
||||
depends_on: {
|
||||
inputs: ['image']
|
||||
}
|
||||
}
|
||||
const node = new ApiNode('API')
|
||||
|
||||
mountedApp = mountBadge()
|
||||
await nextTick()
|
||||
callNodeCreated(node)
|
||||
|
||||
node.onConnectionsChange?.(1, 0, true, undefined, inputSlot('image'))
|
||||
|
||||
expect(triggerPriceRecalculationMock).toHaveBeenCalledWith(node)
|
||||
})
|
||||
|
||||
it('updates subgraph credit badges from registered extension hooks', async () => {
|
||||
const nodes = [new LGraphNode('one'), new LGraphNode('two')]
|
||||
appState.graph.nodes = nodes
|
||||
|
||||
mountedApp = mountBadge()
|
||||
await nextTick()
|
||||
await registeredExtension().init?.(comfyApp())
|
||||
await registeredExtension().afterConfigureGraph?.([], comfyApp())
|
||||
|
||||
const setGraphHandler = addEventListenerMock.mock.calls.find(
|
||||
([event]) => event === 'litegraph:set-graph'
|
||||
)?.[1]
|
||||
const convertedHandler = addEventListenerMock.mock.calls.find(
|
||||
([event]) => event === 'subgraph-converted'
|
||||
)?.[1]
|
||||
setGraphHandler?.()
|
||||
convertedHandler?.({ detail: { subgraphNode: nodes[0] } })
|
||||
|
||||
expect(updateSubgraphCreditsMock).toHaveBeenCalledWith(nodes[0])
|
||||
expect(updateSubgraphCreditsMock).toHaveBeenCalledWith(nodes[1])
|
||||
})
|
||||
|
||||
it('handles empty graph nodes during registered extension hooks', async () => {
|
||||
const noNodes: unknown = undefined
|
||||
appState.graph.nodes = noNodes as LGraphNode[]
|
||||
|
||||
mountedApp = mountBadge()
|
||||
await nextTick()
|
||||
await registeredExtension().init?.(comfyApp())
|
||||
await registeredExtension().afterConfigureGraph?.([], comfyApp())
|
||||
|
||||
const setGraphHandler = addEventListenerMock.mock.calls.find(
|
||||
([event]) => event === 'litegraph:set-graph'
|
||||
)?.[1]
|
||||
setGraphHandler?.()
|
||||
|
||||
expect(updateSubgraphCreditsMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -65,29 +65,4 @@ describe('useNodeCanvasImagePreview', () => {
|
||||
|
||||
expect(imagePreviewWidget).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does nothing when removing from a node without widgets', () => {
|
||||
const node = Object.assign(new LGraphNode('test'), { widgets: undefined })
|
||||
|
||||
useNodeCanvasImagePreview().removeCanvasImagePreview(node)
|
||||
|
||||
expect(imagePreviewWidget).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('removes an existing preview widget and calls its cleanup', () => {
|
||||
const node = new LGraphNode('test')
|
||||
const widget = node.addWidget(
|
||||
'text',
|
||||
'$$canvas-image-preview',
|
||||
'',
|
||||
() => undefined,
|
||||
{}
|
||||
)
|
||||
widget.onRemove = vi.fn()
|
||||
|
||||
useNodeCanvasImagePreview().removeCanvasImagePreview(node)
|
||||
|
||||
expect(widget.onRemove).toHaveBeenCalledOnce()
|
||||
expect(node.widgets).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
|
||||
import { useNodeImage, useNodeVideo } from '@/composables/node/useNodeImage'
|
||||
import { useNodeVideo } from '@/composables/node/useNodeImage'
|
||||
import { createMockMediaNode } from '@/renderer/extensions/vueNodes/widgets/composables/domWidgetTestUtils'
|
||||
|
||||
const { canvasInteractionsMock, nodeOutputStoreMock } = vi.hoisted(() => ({
|
||||
@@ -28,25 +28,8 @@ describe('useNodeVideo', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
function installMockImage() {
|
||||
const images: HTMLImageElement[] = []
|
||||
class MockImage {
|
||||
onload: ((event: Event) => void) | null = null
|
||||
onerror: ((event: Event) => void) | null = null
|
||||
src = ''
|
||||
|
||||
constructor() {
|
||||
const self: unknown = this
|
||||
images.push(self as HTMLImageElement)
|
||||
}
|
||||
}
|
||||
vi.stubGlobal('Image', MockImage)
|
||||
return images
|
||||
}
|
||||
|
||||
async function setup() {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
@@ -110,103 +93,4 @@ describe('useNodeVideo', () => {
|
||||
expect(canvasInteractionsMock.handlePointerMove).not.toHaveBeenCalled()
|
||||
expect(canvasInteractionsMock.handlePointerDown).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('loads image previews and marks the graph dirty', async () => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
const images = installMockImage()
|
||||
const graph = { setDirtyCanvas: vi.fn() }
|
||||
const node = createMockMediaNode({ graph })
|
||||
const callback = vi.fn()
|
||||
nodeOutputStoreMock.getNodeImageUrls.mockReturnValue(['http://image/1.png'])
|
||||
|
||||
const { showPreview } = useNodeImage(node, callback)
|
||||
showPreview({ block: true })
|
||||
images[0].onload?.(new Event('load'))
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(node.previewMediaType).toBe('image')
|
||||
expect(node.imageIndex).toBeNull()
|
||||
expect(node.imgs).toEqual([images[0]])
|
||||
expect(node.isLoading).toBe(false)
|
||||
expect(callback).toHaveBeenCalledTimes(1)
|
||||
expect(graph.setDirtyCanvas).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('does not start image loads while already loading or without output URLs', () => {
|
||||
vi.clearAllMocks()
|
||||
const images = installMockImage()
|
||||
const node = createMockMediaNode()
|
||||
const { showPreview } = useNodeImage(node)
|
||||
|
||||
node.isLoading = true
|
||||
showPreview()
|
||||
node.isLoading = false
|
||||
nodeOutputStoreMock.getNodeImageUrls.mockReturnValue(undefined)
|
||||
showPreview()
|
||||
|
||||
expect(images).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('retries image loading once when the first load fails', async () => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
const images = installMockImage()
|
||||
const graph = { setDirtyCanvas: vi.fn() }
|
||||
const node = createMockMediaNode({ graph })
|
||||
nodeOutputStoreMock.getNodeImageUrls.mockReturnValue([
|
||||
'http://image/missing.png'
|
||||
])
|
||||
|
||||
const staleImgs = [document.createElement('img')]
|
||||
node.imgs = staleImgs
|
||||
|
||||
const { showPreview } = useNodeImage(node)
|
||||
showPreview()
|
||||
images[0].onerror?.(new Event('error'))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
images[1].onerror?.(new Event('error'))
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(images).toHaveLength(2)
|
||||
// Failed loads never resolve to elements, so existing previews are untouched
|
||||
expect(node.imgs).toBe(staleImgs)
|
||||
expect(graph.setDirtyCanvas).not.toHaveBeenCalled()
|
||||
expect(node.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
it('reuses an existing video-preview widget when loading a video', async () => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
nodeOutputStoreMock.getNodeImageUrls.mockReturnValue(['http://video/1.mp4'])
|
||||
const node = createMockMediaNode({
|
||||
graph: { setDirtyCanvas: vi.fn() }
|
||||
})
|
||||
node.widgets.push({
|
||||
name: 'video-preview',
|
||||
element: document.createElement('div')
|
||||
})
|
||||
const callback = vi.fn()
|
||||
|
||||
const createdVideos: HTMLVideoElement[] = []
|
||||
const realCreateElement = document.createElement.bind(document)
|
||||
vi.spyOn(document, 'createElement').mockImplementation(
|
||||
(tag: string, opts?: ElementCreationOptions) => {
|
||||
const el = realCreateElement(tag, opts)
|
||||
if (tag === 'video') createdVideos.push(el as HTMLVideoElement)
|
||||
return el
|
||||
}
|
||||
)
|
||||
|
||||
const { showPreview } = useNodeVideo(node, callback)
|
||||
showPreview()
|
||||
const video = createdVideos[0]
|
||||
video.onloadeddata?.(new Event('loadeddata'))
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(node.addDOMWidget).not.toHaveBeenCalled()
|
||||
expect(node.videoContainer?.firstChild).toBe(video)
|
||||
expect(callback).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { CREDITS_PER_USD, formatCredits } from '@/base/credits/comfyCredits'
|
||||
import {
|
||||
@@ -14,7 +12,6 @@ import {
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { ComfyNodeDef, PriceBadge } from '@/schemas/nodeDefSchema'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
@@ -126,35 +123,6 @@ function createMockNode(
|
||||
})
|
||||
}
|
||||
|
||||
async function resolveDisplayPrice(
|
||||
node: LGraphNode,
|
||||
widgetOverrides?: ReadonlyMap<string, unknown>
|
||||
): Promise<string> {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
getNodeDisplayPrice(node, widgetOverrides)
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
return getNodeDisplayPrice(node, widgetOverrides)
|
||||
}
|
||||
|
||||
function createStoredNodeDef(
|
||||
name: string,
|
||||
price_badge?: PriceBadge
|
||||
): ComfyNodeDef {
|
||||
return {
|
||||
name,
|
||||
display_name: name,
|
||||
description: '',
|
||||
category: 'test',
|
||||
input: { required: {}, optional: {} },
|
||||
output: [],
|
||||
output_name: [],
|
||||
output_is_list: [],
|
||||
output_node: false,
|
||||
python_module: 'test',
|
||||
price_badge
|
||||
} as ComfyNodeDef
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Tests
|
||||
// -----------------------------------------------------------------------------
|
||||
@@ -221,32 +189,6 @@ describe('useNodePricing', () => {
|
||||
expect(price).toBe(creditsLabel(0.5))
|
||||
})
|
||||
|
||||
it('should parse numeric strings and reject blank or invalid numbers', async () => {
|
||||
const expression =
|
||||
'{"type":"usd","usd": (widgets.count != null) ? widgets.count * 0.01 : 0.20}'
|
||||
const badge = priceBadge(expression, [{ name: 'count', type: 'INT' }])
|
||||
|
||||
const parsedNode = createMockNodeWithPriceBadge(
|
||||
'TestNumericStringNode',
|
||||
badge,
|
||||
[{ name: 'count', value: ' 5 ' }]
|
||||
)
|
||||
const blankNode = createMockNodeWithPriceBadge(
|
||||
'TestBlankNumericStringNode',
|
||||
badge,
|
||||
[{ name: 'count', value: ' ' }]
|
||||
)
|
||||
const invalidNode = createMockNodeWithPriceBadge(
|
||||
'TestInvalidNumericStringNode',
|
||||
badge,
|
||||
[{ name: 'count', value: 'five' }]
|
||||
)
|
||||
|
||||
expect(await resolveDisplayPrice(parsedNode)).toBe(creditsLabel(0.05))
|
||||
expect(await resolveDisplayPrice(blankNode)).toBe(creditsLabel(0.2))
|
||||
expect(await resolveDisplayPrice(invalidNode)).toBe(creditsLabel(0.2))
|
||||
})
|
||||
|
||||
it('should handle COMBO widget with numeric value', async () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNodeWithPriceBadge(
|
||||
@@ -280,19 +222,6 @@ describe('useNodePricing', () => {
|
||||
expect(price).toBe(creditsLabel(0.1))
|
||||
})
|
||||
|
||||
it('should preserve boolean combo values', async () => {
|
||||
const node = createMockNodeWithPriceBadge(
|
||||
'TestComboBooleanNode',
|
||||
priceBadge(
|
||||
'(widgets.enabled = false) ? {"type":"usd","usd":0.04} : {"type":"usd","usd":0.08}',
|
||||
[{ name: 'enabled', type: 'COMBO' }]
|
||||
),
|
||||
[{ name: 'enabled', value: false }]
|
||||
)
|
||||
|
||||
expect(await resolveDisplayPrice(node)).toBe(creditsLabel(0.04))
|
||||
})
|
||||
|
||||
it('should handle BOOLEAN widget', async () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNodeWithPriceBadge(
|
||||
@@ -309,64 +238,6 @@ describe('useNodePricing', () => {
|
||||
expect(price).toBe(creditsLabel(0.1))
|
||||
})
|
||||
|
||||
it('should parse BOOLEAN widget string values', async () => {
|
||||
const badge = priceBadge(
|
||||
'{"type":"usd","usd": widgets.premium ? 0.10 : 0.05}',
|
||||
[{ name: 'premium', type: 'BOOLEAN' }]
|
||||
)
|
||||
const enabledNode = createMockNodeWithPriceBadge(
|
||||
'TestBooleanStringTrueNode',
|
||||
badge,
|
||||
[{ name: 'premium', value: ' TRUE ' }]
|
||||
)
|
||||
const disabledNode = createMockNodeWithPriceBadge(
|
||||
'TestBooleanStringFalseNode',
|
||||
badge,
|
||||
[{ name: 'premium', value: 'false' }]
|
||||
)
|
||||
|
||||
expect(await resolveDisplayPrice(enabledNode)).toBe(creditsLabel(0.1))
|
||||
expect(await resolveDisplayPrice(disabledNode)).toBe(creditsLabel(0.05))
|
||||
})
|
||||
|
||||
it('should reject invalid BOOLEAN strings', async () => {
|
||||
const node = createMockNodeWithPriceBadge(
|
||||
'TestInvalidBooleanStringNode',
|
||||
priceBadge(
|
||||
'{"type":"usd","usd": widgets.premium = null ? 0.05 : 0.10}',
|
||||
[{ name: 'premium', type: 'BOOLEAN' }]
|
||||
),
|
||||
[{ name: 'premium', value: 'sometimes' }]
|
||||
)
|
||||
|
||||
expect(await resolveDisplayPrice(node)).toBe(creditsLabel(0.05))
|
||||
})
|
||||
|
||||
it('should reject non-boolean values for BOOLEAN widgets', async () => {
|
||||
const node = createMockNodeWithPriceBadge(
|
||||
'TestInvalidBooleanNumberNode',
|
||||
priceBadge(
|
||||
'{"type":"usd","usd": widgets.premium = null ? 0.05 : 0.10}',
|
||||
[{ name: 'premium', type: 'BOOLEAN' }]
|
||||
),
|
||||
[{ name: 'premium', value: 1 }]
|
||||
)
|
||||
|
||||
expect(await resolveDisplayPrice(node)).toBe(creditsLabel(0.05))
|
||||
})
|
||||
|
||||
it('should reject object values for numeric widgets', async () => {
|
||||
const node = createMockNodeWithPriceBadge(
|
||||
'TestObjectNumericNode',
|
||||
priceBadge('{"type":"usd","usd": widgets.count = null ? 0.05 : 0.10}', [
|
||||
{ name: 'count', type: 'INT' }
|
||||
]),
|
||||
[{ name: 'count', value: { count: 5 } }]
|
||||
)
|
||||
|
||||
expect(await resolveDisplayPrice(node)).toBe(creditsLabel(0.05))
|
||||
})
|
||||
|
||||
it('should handle STRING widget (lowercased)', async () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNodeWithPriceBadge(
|
||||
@@ -597,42 +468,6 @@ describe('useNodePricing', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('dependency context', () => {
|
||||
it('should prefer widget overrides over node widget values', async () => {
|
||||
const node = createMockNodeWithPriceBadge(
|
||||
'TestWidgetOverrideNode',
|
||||
priceBadge('{"type":"usd","usd": widgets.count * 0.01}', [
|
||||
{ name: 'count', type: 'INT' }
|
||||
]),
|
||||
[{ name: 'count', value: 2 }]
|
||||
)
|
||||
|
||||
const price = await resolveDisplayPrice(node, new Map([['count', '7']]))
|
||||
|
||||
expect(price).toBe(creditsLabel(0.07))
|
||||
})
|
||||
|
||||
it('should treat missing input group arrays as zero connected inputs', async () => {
|
||||
const node = Object.assign(createMockLGraphNode(), {
|
||||
widgets: [],
|
||||
constructor: {
|
||||
nodeData: {
|
||||
name: 'TestMissingInputGroupArrayNode',
|
||||
api_node: true,
|
||||
price_badge: priceBadge(
|
||||
'{"type":"usd","usd": (inputGroups.images = 0) ? 0.05 : 0.10}',
|
||||
[],
|
||||
[],
|
||||
['images']
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(await resolveDisplayPrice(node)).toBe(creditsLabel(0.05))
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should return empty string for non-API nodes', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
@@ -706,44 +541,6 @@ describe('useNodePricing', () => {
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe(creditsLabel(0.05))
|
||||
})
|
||||
|
||||
it('should default missing price badge engine and dependency arrays', async () => {
|
||||
const bareBadge = {
|
||||
expr: '{"type":"usd","usd":0.05}'
|
||||
} as PriceBadge
|
||||
const node = createMockNodeWithPriceBadge('TestBareBadgeNode', bareBadge)
|
||||
|
||||
expect(await resolveDisplayPrice(node)).toBe(creditsLabel(0.05))
|
||||
|
||||
const { getNodePricingConfig } = useNodePricing()
|
||||
expect(getNodePricingConfig(node)).toMatchObject({
|
||||
engine: 'jsonata',
|
||||
depends_on: {
|
||||
widgets: [],
|
||||
inputs: [],
|
||||
input_groups: []
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('should ignore non-jsonata pricing engines', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const literalEngineBadge: unknown = {
|
||||
engine: 'literal',
|
||||
expr: '{"type":"usd","usd":0.05}',
|
||||
depends_on: {
|
||||
widgets: [],
|
||||
inputs: [],
|
||||
input_groups: []
|
||||
}
|
||||
}
|
||||
const node = createMockNodeWithPriceBadge(
|
||||
'TestUnsupportedEngineNode',
|
||||
literalEngineBadge as PriceBadge
|
||||
)
|
||||
|
||||
expect(getNodeDisplayPrice(node)).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getNodePricingConfig', () => {
|
||||
@@ -798,107 +595,6 @@ describe('useNodePricing', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('node type pricing dependencies', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
it('returns empty dependency metadata for node types without pricing', () => {
|
||||
const store = useNodeDefStore()
|
||||
store.addNodeDef(createStoredNodeDef('UnpricedNode'))
|
||||
const {
|
||||
getInputGroupPrefixes,
|
||||
getInputNames,
|
||||
getRelevantWidgetNames,
|
||||
hasDynamicPricing
|
||||
} = useNodePricing()
|
||||
|
||||
expect(getRelevantWidgetNames('UnpricedNode')).toEqual([])
|
||||
expect(hasDynamicPricing('UnpricedNode')).toBe(false)
|
||||
expect(getInputGroupPrefixes('UnpricedNode')).toEqual([])
|
||||
expect(getInputNames('UnpricedNode')).toEqual([])
|
||||
})
|
||||
|
||||
it('dedupes dynamic pricing dependencies while preserving order', () => {
|
||||
const store = useNodeDefStore()
|
||||
store.addNodeDef(
|
||||
createStoredNodeDef(
|
||||
'DynamicPricingNode',
|
||||
priceBadge(
|
||||
'{"type":"usd","usd":0.05}',
|
||||
[
|
||||
{ name: 'seed', type: 'INT' },
|
||||
{ name: 'quality', type: 'COMBO' }
|
||||
],
|
||||
['image', 'seed'],
|
||||
['clips', 'image']
|
||||
)
|
||||
)
|
||||
)
|
||||
const {
|
||||
getInputGroupPrefixes,
|
||||
getInputNames,
|
||||
getRelevantWidgetNames,
|
||||
hasDynamicPricing
|
||||
} = useNodePricing()
|
||||
|
||||
expect(getRelevantWidgetNames('DynamicPricingNode')).toEqual([
|
||||
'seed',
|
||||
'quality',
|
||||
'image',
|
||||
'clips'
|
||||
])
|
||||
expect(hasDynamicPricing('DynamicPricingNode')).toBe(true)
|
||||
expect(getInputGroupPrefixes('DynamicPricingNode')).toEqual([
|
||||
'clips',
|
||||
'image'
|
||||
])
|
||||
expect(getInputNames('DynamicPricingNode')).toEqual(['image', 'seed'])
|
||||
})
|
||||
|
||||
it('handles fixed pricing metadata without dependencies', () => {
|
||||
const store = useNodeDefStore()
|
||||
store.addNodeDef(
|
||||
createStoredNodeDef(
|
||||
'FixedPricingNode',
|
||||
priceBadge('{"type":"usd","usd":0.05}')
|
||||
)
|
||||
)
|
||||
const {
|
||||
getInputGroupPrefixes,
|
||||
getInputNames,
|
||||
getRelevantWidgetNames,
|
||||
hasDynamicPricing
|
||||
} = useNodePricing()
|
||||
|
||||
expect(getRelevantWidgetNames('FixedPricingNode')).toEqual([])
|
||||
expect(hasDynamicPricing('FixedPricingNode')).toBe(false)
|
||||
expect(getInputGroupPrefixes('FixedPricingNode')).toEqual([])
|
||||
expect(getInputNames('FixedPricingNode')).toEqual([])
|
||||
})
|
||||
|
||||
it('handles price badges with omitted dependency metadata', () => {
|
||||
const store = useNodeDefStore()
|
||||
store.addNodeDef(
|
||||
createStoredNodeDef('BareDependencyNode', {
|
||||
engine: 'jsonata',
|
||||
expr: '{"type":"usd","usd":0.05}'
|
||||
} as PriceBadge)
|
||||
)
|
||||
const {
|
||||
getInputGroupPrefixes,
|
||||
getInputNames,
|
||||
getRelevantWidgetNames,
|
||||
hasDynamicPricing
|
||||
} = useNodePricing()
|
||||
|
||||
expect(getRelevantWidgetNames('BareDependencyNode')).toEqual([])
|
||||
expect(hasDynamicPricing('BareDependencyNode')).toBe(false)
|
||||
expect(getInputGroupPrefixes('BareDependencyNode')).toEqual([])
|
||||
expect(getInputNames('BareDependencyNode')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('reactive revision', () => {
|
||||
it('bumps pricingRevision after an async evaluation resolves (Nodes 1.0 mode)', async () => {
|
||||
const { getNodeDisplayPrice, pricingRevision } = useNodePricing()
|
||||
@@ -959,20 +655,6 @@ describe('useNodePricing', () => {
|
||||
expect(second).toBe(first)
|
||||
expect(pricingRevision.value).toBe(tickAfterFirst)
|
||||
})
|
||||
|
||||
it('does not schedule duplicate work for the same in-flight signature', async () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNodeWithPriceBadge(
|
||||
'TestInFlightSignatureNode',
|
||||
priceBadge('{"type":"usd","usd":0.05}')
|
||||
)
|
||||
|
||||
expect(getNodeDisplayPrice(node)).toBe('')
|
||||
expect(getNodeDisplayPrice(node)).toBe('')
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
|
||||
expect(getNodeDisplayPrice(node)).toBe(creditsLabel(0.05))
|
||||
})
|
||||
})
|
||||
|
||||
describe('getNodeRevisionRef', () => {
|
||||
@@ -1061,16 +743,6 @@ describe('useNodePricing', () => {
|
||||
expect(price).toBe('')
|
||||
})
|
||||
|
||||
it('should reuse the cached empty label after runtime failures', async () => {
|
||||
const node = createMockNodeWithPriceBadge(
|
||||
'TestCachedRuntimeErrorNode',
|
||||
priceBadge('$lookup(undefined, "key")')
|
||||
)
|
||||
|
||||
expect(await resolveDisplayPrice(node)).toBe('')
|
||||
expect(await resolveDisplayPrice(node)).toBe('')
|
||||
})
|
||||
|
||||
it('should return empty string for invalid PricingResult type', async () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNodeWithPriceBadge(
|
||||
@@ -1296,21 +968,8 @@ describe('formatPricingResult', () => {
|
||||
expect(result).toBe('~10.6')
|
||||
})
|
||||
|
||||
it('should parse string usd values with default approximate formatting', () => {
|
||||
const result = formatPricingResult(
|
||||
{ type: 'usd', usd: '0.05' },
|
||||
{ valueOnly: true, defaults: { approximate: true } }
|
||||
)
|
||||
expect(result).toBe('~10.6')
|
||||
})
|
||||
|
||||
it('should return empty for null usd', () => {
|
||||
const result = formatPricingResult({ type: 'usd', usd: null })
|
||||
expect(result).toBe('')
|
||||
})
|
||||
|
||||
it('should return empty for blank string usd', () => {
|
||||
const result = formatPricingResult({ type: 'usd', usd: ' ' })
|
||||
const result = formatPricingResult({ type: 'usd', usd: null as never })
|
||||
expect(result).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -1340,14 +999,6 @@ describe('formatPricingResult', () => {
|
||||
)
|
||||
expect(result).toBe('10.6')
|
||||
})
|
||||
|
||||
it('should parse string range values with default approximate formatting', () => {
|
||||
const result = formatPricingResult(
|
||||
{ type: 'range_usd', min_usd: '0.05', max_usd: '0.1' },
|
||||
{ valueOnly: true, defaults: { approximate: true } }
|
||||
)
|
||||
expect(result).toBe('~10.6-21.1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('type: list_usd', () => {
|
||||
@@ -1366,22 +1017,6 @@ describe('formatPricingResult', () => {
|
||||
)
|
||||
expect(result).toBe('10.6/21.1')
|
||||
})
|
||||
|
||||
it('should return valueOnly format with approximate prefix', () => {
|
||||
const result = formatPricingResult(
|
||||
{ type: 'list_usd', usd: [0.05, 0.1] },
|
||||
{ valueOnly: true, defaults: { approximate: true } }
|
||||
)
|
||||
expect(result).toBe('~10.6/21.1')
|
||||
})
|
||||
|
||||
it('should return empty when list value is not an array', () => {
|
||||
const result = formatPricingResult({
|
||||
type: 'list_usd',
|
||||
usd: 'not-a-list'
|
||||
})
|
||||
expect(result).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('type: text', () => {
|
||||
@@ -1389,11 +1024,6 @@ describe('formatPricingResult', () => {
|
||||
const result = formatPricingResult({ type: 'text', text: 'Free' })
|
||||
expect(result).toBe('Free')
|
||||
})
|
||||
|
||||
it('should return empty when text is missing', () => {
|
||||
const result = formatPricingResult({ type: 'text' })
|
||||
expect(result).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('legacy format', () => {
|
||||
@@ -1538,20 +1168,6 @@ describe('evaluateNodeDefPricing', () => {
|
||||
expect(result).toBe('10.6')
|
||||
})
|
||||
|
||||
it('should evaluate price badges with omitted dependency metadata', async () => {
|
||||
const nodeDef = createMockNodeDef({
|
||||
name: 'BareNodeDefPriceBadge',
|
||||
price_badge: {
|
||||
engine: 'jsonata',
|
||||
expr: '{"type":"usd","usd":0.05}'
|
||||
} as PriceBadge
|
||||
})
|
||||
|
||||
const result = await evaluateNodeDefPricing(nodeDef)
|
||||
|
||||
expect(result).toBe('10.6')
|
||||
})
|
||||
|
||||
it('should use default value from input spec', async () => {
|
||||
const nodeDef = createMockNodeDef({
|
||||
name: 'DefaultValueNode',
|
||||
@@ -1574,29 +1190,6 @@ describe('evaluateNodeDefPricing', () => {
|
||||
expect(result).toBe('21.1') // 10 * 0.01 = 0.1 USD = 21.1 credits
|
||||
})
|
||||
|
||||
it('should use default value from optional input spec', async () => {
|
||||
const nodeDef = createMockNodeDef({
|
||||
name: 'OptionalDefaultValueNode',
|
||||
price_badge: {
|
||||
engine: 'jsonata',
|
||||
expr: '{"type":"usd","usd": widgets.count * 0.01}',
|
||||
depends_on: {
|
||||
widgets: [{ name: 'count', type: 'INT' }],
|
||||
inputs: [],
|
||||
input_groups: []
|
||||
}
|
||||
},
|
||||
input: {
|
||||
required: {},
|
||||
optional: {
|
||||
count: ['INT', { default: 4 }]
|
||||
}
|
||||
}
|
||||
})
|
||||
const result = await evaluateNodeDefPricing(nodeDef)
|
||||
expect(result).toBe('8.4')
|
||||
})
|
||||
|
||||
it('should use first option for COMBO without default', async () => {
|
||||
const nodeDef = createMockNodeDef({
|
||||
name: 'ComboNode',
|
||||
@@ -1672,30 +1265,6 @@ describe('evaluateNodeDefPricing', () => {
|
||||
expect(result).toBe('10.6')
|
||||
})
|
||||
|
||||
it('should handle combo option arrays with primitive values', async () => {
|
||||
const nodeDef = createMockNodeDef({
|
||||
name: 'PrimitiveOptionsNode',
|
||||
price_badge: {
|
||||
engine: 'jsonata',
|
||||
expr: '{"type":"usd","usd": widgets.mode = "fast" ? 0.05 : 0.10}',
|
||||
depends_on: {
|
||||
widgets: [{ name: 'mode', type: 'COMBO' }],
|
||||
inputs: [],
|
||||
input_groups: []
|
||||
}
|
||||
},
|
||||
input: {
|
||||
required: {
|
||||
mode: ['COMBO', { options: ['fast', 'slow'] }]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const result = await evaluateNodeDefPricing(nodeDef)
|
||||
|
||||
expect(result).toBe('10.6')
|
||||
})
|
||||
|
||||
it('should assume inputs disconnected in preview', async () => {
|
||||
const nodeDef = createMockNodeDef({
|
||||
name: 'InputConnectedNode',
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
const mockTextPreviewWidget = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock(
|
||||
'@/renderer/extensions/vueNodes/widgets/composables/useProgressTextWidget',
|
||||
() => ({
|
||||
useTextPreviewWidget: () => mockTextPreviewWidget
|
||||
})
|
||||
)
|
||||
|
||||
import { useNodeProgressText } from './useNodeProgressText'
|
||||
|
||||
function node(widgets?: IBaseWidget[]): LGraphNode {
|
||||
return createMockLGraphNode({ widgets, setDirtyCanvas: vi.fn() })
|
||||
}
|
||||
|
||||
describe('useNodeProgressText', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockTextPreviewWidget.mockImplementation(
|
||||
(_node: LGraphNode, spec: { name: string; type: string }) => ({
|
||||
name: spec.name,
|
||||
type: spec.type,
|
||||
value: ''
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('updates an existing text preview widget', () => {
|
||||
const existing = { name: '$$node-text-preview', value: '' } as IBaseWidget
|
||||
const graphNode = node([existing])
|
||||
const { showTextPreview } = useNodeProgressText()
|
||||
|
||||
showTextPreview(graphNode, 'running')
|
||||
|
||||
expect(existing.value).toBe('running')
|
||||
expect(mockTextPreviewWidget).not.toHaveBeenCalled()
|
||||
expect(graphNode.setDirtyCanvas).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('creates a text preview widget when one is missing', () => {
|
||||
const createdWidget = fromPartial<IBaseWidget>({
|
||||
name: '$$node-text-preview',
|
||||
type: 'progressText',
|
||||
value: ''
|
||||
})
|
||||
mockTextPreviewWidget.mockReturnValueOnce(createdWidget)
|
||||
const graphNode = node([])
|
||||
const { showTextPreview } = useNodeProgressText()
|
||||
|
||||
showTextPreview(graphNode, 'queued')
|
||||
|
||||
expect(mockTextPreviewWidget).toHaveBeenCalledWith(graphNode, {
|
||||
name: '$$node-text-preview',
|
||||
type: 'progressText'
|
||||
})
|
||||
expect(createdWidget.value).toBe('queued')
|
||||
})
|
||||
|
||||
it('removes an existing preview widget and calls its cleanup', () => {
|
||||
const onRemove = vi.fn()
|
||||
const keep = { name: 'other' } as IBaseWidget
|
||||
const preview = fromPartial<IBaseWidget & { onRemove?: () => void }>({
|
||||
name: '$$node-text-preview',
|
||||
onRemove
|
||||
})
|
||||
const graphNode = node([keep, preview])
|
||||
const { removeTextPreview } = useNodeProgressText()
|
||||
|
||||
removeTextPreview(graphNode)
|
||||
|
||||
expect(onRemove).toHaveBeenCalledOnce()
|
||||
expect(graphNode.widgets).toEqual([keep])
|
||||
})
|
||||
|
||||
it('does nothing when there are no widgets or no preview widget', () => {
|
||||
const { removeTextPreview } = useNodeProgressText()
|
||||
const withoutWidgets = node()
|
||||
const withoutPreview = node([{ name: 'other' } as IBaseWidget])
|
||||
|
||||
removeTextPreview(withoutWidgets)
|
||||
removeTextPreview(withoutPreview)
|
||||
|
||||
expect(withoutWidgets.widgets).toBeUndefined()
|
||||
expect(withoutPreview.widgets).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, vi } from 'vitest'
|
||||
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
@@ -6,34 +6,26 @@ import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { subgraphTest } from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphFixtures'
|
||||
|
||||
import { usePriceBadge } from '@/composables/node/usePriceBadge'
|
||||
import { adjustColor } from '@/utils/colorUtil'
|
||||
|
||||
const getNodeDisplayPrice = vi.fn(
|
||||
(_node: LGraphNode, overrides?: ReadonlyMap<string, unknown>) =>
|
||||
String(overrides?.get('prompt') ?? 'missing override')
|
||||
)
|
||||
|
||||
const mockPalette = vi.hoisted(() => ({
|
||||
completedActivePalette: {
|
||||
light_theme: false,
|
||||
colors: {
|
||||
litegraph_base: {
|
||||
BADGE_FG_COLOR: '#ffffff'
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/node/useNodePricing', () => ({
|
||||
useNodePricing: () => ({ getNodeDisplayPrice })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workspace/colorPaletteStore', () => ({
|
||||
useColorPaletteStore: () => mockPalette
|
||||
useColorPaletteStore: () => ({
|
||||
completedActivePalette: {
|
||||
light_theme: false,
|
||||
colors: { litegraph_base: {} }
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
const { updateSubgraphCredits, getCreditsBadge, isCreditsBadge } =
|
||||
usePriceBadge()
|
||||
const { updateSubgraphCredits, getCreditsBadge } = usePriceBadge()
|
||||
|
||||
const mockNode = new LGraphNode('mock node')
|
||||
mockNode.badges = [getCreditsBadge('$0.05/Run')]
|
||||
@@ -44,34 +36,6 @@ function getBadgeText(node: LGraphNode): string {
|
||||
}
|
||||
|
||||
describe('subgraph pricing', () => {
|
||||
beforeEach(() => {
|
||||
mockPalette.completedActivePalette.light_theme = false
|
||||
})
|
||||
|
||||
it('identifies credit badges and ignores unrelated badges', () => {
|
||||
expect(isCreditsBadge(getCreditsBadge('$0.05/Run'))).toBe(true)
|
||||
expect(isCreditsBadge(() => getCreditsBadge('$0.05/Run'))).toBe(true)
|
||||
expect(isCreditsBadge({ text: 'other' })).toBe(false)
|
||||
})
|
||||
|
||||
it('uses the adjusted credits background in light themes', () => {
|
||||
mockPalette.completedActivePalette.light_theme = true
|
||||
|
||||
expect(getCreditsBadge('$0.05/Run').bgColor).toBe(
|
||||
adjustColor('#8D6932', { lightness: 0.5 })
|
||||
)
|
||||
})
|
||||
|
||||
it('does nothing for non-subgraph nodes', () => {
|
||||
const node = new LGraphNode('plain node')
|
||||
const badge = getCreditsBadge('$0.05/Run')
|
||||
node.badges = [badge]
|
||||
|
||||
updateSubgraphCredits(node)
|
||||
|
||||
expect(node.badges).toEqual([badge])
|
||||
})
|
||||
|
||||
subgraphTest(
|
||||
'should not display badge for subgraphs without API nodes',
|
||||
({ subgraphWithNode }) => {
|
||||
|
||||
@@ -151,9 +151,7 @@ describe('useComputedWithWidgetWatch', () => {
|
||||
})
|
||||
|
||||
it('should handle nodes without widgets gracefully', () => {
|
||||
const mockNode = Object.assign(createMockLGraphNode(), {
|
||||
widgets: undefined
|
||||
}) as LGraphNode
|
||||
const mockNode = createMockNode([])
|
||||
|
||||
const computedWithWidgetWatch = useComputedWithWidgetWatch(mockNode)
|
||||
|
||||
@@ -162,85 +160,6 @@ describe('useComputedWithWidgetWatch', () => {
|
||||
expect(computedValue.value).toBe('no widgets')
|
||||
})
|
||||
|
||||
it('observes named input connection changes when requested', async () => {
|
||||
const mockNode = Object.assign(
|
||||
createMockNode([{ name: 'width', value: 1 }]),
|
||||
{
|
||||
inputs: [{ name: 'image' }],
|
||||
onConnectionsChange: undefined as
|
||||
| ((type: unknown, index: number, isConnected: boolean) => void)
|
||||
| undefined
|
||||
}
|
||||
)
|
||||
|
||||
const computedWithWidgetWatch = useComputedWithWidgetWatch(mockNode, {
|
||||
widgetNames: ['width', 'image'],
|
||||
triggerCanvasRedraw: true
|
||||
})
|
||||
|
||||
let runs = 0
|
||||
const computedValue = computedWithWidgetWatch(() => ++runs)
|
||||
expect(computedValue.value).toBe(1)
|
||||
|
||||
mockNode.onConnectionsChange?.('input', 0, true)
|
||||
await nextTick()
|
||||
|
||||
expect(computedValue.value).toBe(2)
|
||||
expect(mockNode.graph?.setDirtyCanvas).toHaveBeenCalledWith(true, true)
|
||||
})
|
||||
|
||||
it('observes connection changes for watched inputs at non-zero slots', async () => {
|
||||
const mockNode = Object.assign(
|
||||
createMockNode([{ name: 'width', value: 1 }]),
|
||||
{
|
||||
inputs: [{ name: 'other' }, { name: 'image' }],
|
||||
onConnectionsChange: undefined as
|
||||
| ((type: unknown, index: number, isConnected: boolean) => void)
|
||||
| undefined
|
||||
}
|
||||
)
|
||||
|
||||
const computedWithWidgetWatch = useComputedWithWidgetWatch(mockNode, {
|
||||
widgetNames: ['width', 'image'],
|
||||
triggerCanvasRedraw: true
|
||||
})
|
||||
|
||||
let runs = 0
|
||||
const computedValue = computedWithWidgetWatch(() => ++runs)
|
||||
expect(computedValue.value).toBe(1)
|
||||
|
||||
mockNode.onConnectionsChange?.('input', 1, true)
|
||||
await nextTick()
|
||||
|
||||
expect(computedValue.value).toBe(2)
|
||||
expect(mockNode.graph?.setDirtyCanvas).toHaveBeenCalledWith(true, true)
|
||||
})
|
||||
|
||||
it('ignores unobserved input connection changes', async () => {
|
||||
const mockNode = Object.assign(
|
||||
createMockNode([{ name: 'width', value: 1 }]),
|
||||
{
|
||||
inputs: [{ name: 'image' }],
|
||||
onConnectionsChange: undefined as
|
||||
| ((type: unknown, index: number, isConnected: boolean) => void)
|
||||
| undefined
|
||||
}
|
||||
)
|
||||
|
||||
const computedWithWidgetWatch = useComputedWithWidgetWatch(mockNode, {
|
||||
widgetNames: ['width', 'image']
|
||||
})
|
||||
|
||||
let runs = 0
|
||||
const computedValue = computedWithWidgetWatch(() => ++runs)
|
||||
expect(computedValue.value).toBe(1)
|
||||
|
||||
mockNode.onConnectionsChange?.('input', 1, true)
|
||||
await nextTick()
|
||||
|
||||
expect(computedValue.value).toBe(1)
|
||||
})
|
||||
|
||||
it('should chain with existing widget callbacks', async () => {
|
||||
const existingCallback = vi.fn()
|
||||
const mockNode = createMockNode([
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { EffectScope } from 'vue'
|
||||
import { effectScope, ref, shallowRef } from 'vue'
|
||||
@@ -13,12 +12,8 @@ afterEach(() => {
|
||||
|
||||
function setup(initial: string[]) {
|
||||
const modelValue = ref(initial)
|
||||
const container = shallowRef<HTMLDivElement | null>(
|
||||
document.createElement('div')
|
||||
)
|
||||
const picker = shallowRef<HTMLInputElement | null>(
|
||||
document.createElement('input')
|
||||
)
|
||||
const container = shallowRef(document.createElement('div'))
|
||||
const picker = shallowRef(document.createElement('input'))
|
||||
const scope = effectScope()
|
||||
scopes.push(scope)
|
||||
const api = scope.run(() =>
|
||||
@@ -27,13 +22,7 @@ function setup(initial: string[]) {
|
||||
return { modelValue, container, picker, ...api }
|
||||
}
|
||||
|
||||
const mouseEvent = () => fromPartial<MouseEvent>({ stopPropagation: vi.fn() })
|
||||
|
||||
function makeInputEvent(value: string): Event {
|
||||
return fromPartial<Event>({
|
||||
target: fromPartial<HTMLInputElement>({ value })
|
||||
})
|
||||
}
|
||||
const mouseEvent = () => ({ stopPropagation: vi.fn() }) as unknown as MouseEvent
|
||||
|
||||
describe('usePaletteSwatchRow', () => {
|
||||
it('appends a default color', () => {
|
||||
@@ -62,29 +51,16 @@ describe('usePaletteSwatchRow', () => {
|
||||
expect(picker.value!.value).toBe('#ffffff')
|
||||
})
|
||||
|
||||
it('tracks the picker index even when the input is unavailable', () => {
|
||||
const { modelValue, picker, openPicker, onPickerInput } = setup([
|
||||
'#000000',
|
||||
'#111111'
|
||||
])
|
||||
picker.value = null
|
||||
|
||||
openPicker(1, mouseEvent())
|
||||
onPickerInput(makeInputEvent('#222222'))
|
||||
|
||||
expect(modelValue.value).toEqual(['#000000', '#222222'])
|
||||
})
|
||||
|
||||
it('writes the picked color back to the open slot', () => {
|
||||
const { modelValue, openPicker, onPickerInput } = setup(['#a', '#b'])
|
||||
openPicker(1, mouseEvent())
|
||||
onPickerInput(makeInputEvent('#123456'))
|
||||
onPickerInput({ target: { value: '#123456' } } as unknown as Event)
|
||||
expect(modelValue.value).toEqual(['#a', '#123456'])
|
||||
})
|
||||
|
||||
it('ignores picker input when no slot is open', () => {
|
||||
const { modelValue, onPickerInput } = setup(['#a'])
|
||||
onPickerInput(makeInputEvent('#123456'))
|
||||
onPickerInput({ target: { value: '#123456' } } as unknown as Event)
|
||||
expect(modelValue.value).toEqual(['#a'])
|
||||
})
|
||||
|
||||
@@ -124,82 +100,6 @@ describe('usePaletteSwatchRow', () => {
|
||||
expect(modelValue.value).toEqual(['#a', '#b'])
|
||||
})
|
||||
|
||||
it('ignores pointer movement before a drag starts', () => {
|
||||
const { modelValue } = setup(['#a', '#b'])
|
||||
|
||||
document.dispatchEvent(
|
||||
new MouseEvent('pointermove', { clientX: 130, clientY: 10, buttons: 1 })
|
||||
)
|
||||
|
||||
expect(modelValue.value).toEqual(['#a', '#b'])
|
||||
})
|
||||
|
||||
it('waits until movement passes the drag threshold', () => {
|
||||
const { modelValue, container, onPointerDown } = setup(['#a', '#b'])
|
||||
const swatch = document.createElement('div')
|
||||
swatch.setAttribute('data-index', '1')
|
||||
container.value!.appendChild(swatch)
|
||||
|
||||
onPointerDown(0, { button: 0, clientX: 10, clientY: 10 } as PointerEvent)
|
||||
document.dispatchEvent(
|
||||
new MouseEvent('pointermove', { clientX: 12, clientY: 11, buttons: 1 })
|
||||
)
|
||||
|
||||
expect(modelValue.value).toEqual(['#a', '#b'])
|
||||
})
|
||||
|
||||
it('ignores active drags when the row container is gone', () => {
|
||||
const { modelValue, container, onPointerDown } = setup(['#a', '#b'])
|
||||
container.value = null
|
||||
|
||||
onPointerDown(0, { button: 0, clientX: 10, clientY: 10 } as PointerEvent)
|
||||
document.dispatchEvent(
|
||||
new MouseEvent('pointermove', { clientX: 130, clientY: 10, buttons: 1 })
|
||||
)
|
||||
|
||||
expect(modelValue.value).toEqual(['#a', '#b'])
|
||||
})
|
||||
|
||||
it('ignores invalid target rows during drag', () => {
|
||||
const { modelValue, container, onPointerDown } = setup(['#a', '#b'])
|
||||
const current = document.createElement('div')
|
||||
current.setAttribute('data-index', '0')
|
||||
const invalid = document.createElement('div')
|
||||
invalid.setAttribute('data-index', '-1')
|
||||
container.value!.append(current, invalid)
|
||||
invalid.getBoundingClientRect = () =>
|
||||
({ left: 100, right: 140, top: 0, bottom: 20, width: 40 }) as DOMRect
|
||||
|
||||
onPointerDown(0, { button: 0, clientX: 10, clientY: 10 } as PointerEvent)
|
||||
document.dispatchEvent(
|
||||
new MouseEvent('pointermove', { clientX: 130, clientY: 10, buttons: 1 })
|
||||
)
|
||||
|
||||
expect(modelValue.value).toEqual(['#a', '#b'])
|
||||
})
|
||||
|
||||
it('cancels drags on pointerup and pointercancel', () => {
|
||||
const { modelValue, container, onPointerDown } = setup(['#a', '#b'])
|
||||
const swatch = document.createElement('div')
|
||||
swatch.setAttribute('data-index', '1')
|
||||
container.value!.appendChild(swatch)
|
||||
swatch.getBoundingClientRect = () =>
|
||||
({ left: 100, right: 140, top: 0, bottom: 20, width: 40 }) as DOMRect
|
||||
|
||||
onPointerDown(0, { button: 0, clientX: 10, clientY: 10 } as PointerEvent)
|
||||
document.dispatchEvent(new PointerEvent('pointerup'))
|
||||
document.dispatchEvent(
|
||||
new MouseEvent('pointermove', { clientX: 130, clientY: 10, buttons: 1 })
|
||||
)
|
||||
onPointerDown(0, { button: 0, clientX: 10, clientY: 10 } as PointerEvent)
|
||||
document.dispatchEvent(new PointerEvent('pointercancel'))
|
||||
document.dispatchEvent(
|
||||
new MouseEvent('pointermove', { clientX: 130, clientY: 10, buttons: 1 })
|
||||
)
|
||||
|
||||
expect(modelValue.value).toEqual(['#a', '#b'])
|
||||
})
|
||||
|
||||
it('ignores non-left-button pointer downs', () => {
|
||||
const { modelValue, container, onPointerDown } = setup(['#a', '#b'])
|
||||
const swatch = document.createElement('div')
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { distribution, downloads } = vi.hoisted(() => ({
|
||||
distribution: { isDesktop: false },
|
||||
downloads: { values: [] as unknown[] }
|
||||
}))
|
||||
|
||||
vi.mock('@/components/sidebar/tabs/ModelLibrarySidebarTab.vue', () => ({
|
||||
default: {}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isDesktop() {
|
||||
return distribution.isDesktop
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/electronDownloadStore', () => ({
|
||||
useElectronDownloadStore: () => ({
|
||||
inProgressDownloads: downloads.values
|
||||
})
|
||||
}))
|
||||
|
||||
describe('useModelLibrarySidebarTab', () => {
|
||||
beforeEach(() => {
|
||||
distribution.isDesktop = false
|
||||
downloads.values = []
|
||||
})
|
||||
|
||||
it('hides the badge outside desktop builds', async () => {
|
||||
distribution.isDesktop = false
|
||||
downloads.values = [{ id: 'download-1' }]
|
||||
const { useModelLibrarySidebarTab } =
|
||||
await import('./useModelLibrarySidebarTab')
|
||||
|
||||
const sidebarTab = useModelLibrarySidebarTab()
|
||||
|
||||
expect((sidebarTab.iconBadge as () => string | null)()).toBeNull()
|
||||
})
|
||||
|
||||
it('shows active desktop download count', async () => {
|
||||
distribution.isDesktop = true
|
||||
downloads.values = [{ id: 'a' }, { id: 'b' }]
|
||||
const { useModelLibrarySidebarTab } =
|
||||
await import('./useModelLibrarySidebarTab')
|
||||
|
||||
const sidebarTab = useModelLibrarySidebarTab()
|
||||
|
||||
expect((sidebarTab.iconBadge as () => string | null)()).toBe('2')
|
||||
})
|
||||
|
||||
it('hides the badge when desktop has no active downloads', async () => {
|
||||
distribution.isDesktop = true
|
||||
const { useModelLibrarySidebarTab } =
|
||||
await import('./useModelLibrarySidebarTab')
|
||||
|
||||
const sidebarTab = useModelLibrarySidebarTab()
|
||||
|
||||
expect((sidebarTab.iconBadge as () => string | null)()).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,48 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { settings } = vi.hoisted(() => ({
|
||||
settings: { newDesign: false }
|
||||
}))
|
||||
|
||||
const legacyComponent = { name: 'NodeLibrarySidebarTab' }
|
||||
const newDesignComponent = { name: 'NodeLibrarySidebarTabV2' }
|
||||
|
||||
vi.mock('@/components/sidebar/tabs/NodeLibrarySidebarTab.vue', () => ({
|
||||
default: legacyComponent
|
||||
}))
|
||||
|
||||
vi.mock('@/components/sidebar/tabs/NodeLibrarySidebarTabV2.vue', () => ({
|
||||
default: newDesignComponent
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: (key: string) =>
|
||||
key === 'Comfy.NodeLibrary.NewDesign' && settings.newDesign
|
||||
})
|
||||
}))
|
||||
|
||||
describe('useNodeLibrarySidebarTab', () => {
|
||||
beforeEach(() => {
|
||||
settings.newDesign = false
|
||||
})
|
||||
|
||||
it('uses the legacy node library component by default', async () => {
|
||||
const { useNodeLibrarySidebarTab } =
|
||||
await import('./useNodeLibrarySidebarTab')
|
||||
|
||||
const tab = useNodeLibrarySidebarTab()
|
||||
if (tab.type !== 'vue') throw new Error('Expected a vue sidebar tab')
|
||||
expect(tab.component).toBe(legacyComponent)
|
||||
})
|
||||
|
||||
it('uses the new node library component when the setting is enabled', async () => {
|
||||
settings.newDesign = true
|
||||
const { useNodeLibrarySidebarTab } =
|
||||
await import('./useNodeLibrarySidebarTab')
|
||||
|
||||
const tab = useNodeLibrarySidebarTab()
|
||||
if (tab.type !== 'vue') throw new Error('Expected a vue sidebar tab')
|
||||
expect(tab.component).toBe(newDesignComponent)
|
||||
})
|
||||
})
|
||||
@@ -1,138 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, defineComponent } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
|
||||
|
||||
import { useTreeFolderOperations } from './useTreeFolderOperations'
|
||||
|
||||
const i18n = createI18n({ legacy: false, locale: 'en', messages: { en: {} } })
|
||||
|
||||
function withI18n<T>(fn: () => T): T {
|
||||
let result!: T
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup() {
|
||||
result = fn()
|
||||
return () => null
|
||||
}
|
||||
})
|
||||
)
|
||||
app.use(i18n)
|
||||
app.mount(document.createElement('div'))
|
||||
return result
|
||||
}
|
||||
|
||||
function makeFolder(
|
||||
overrides: Partial<RenderedTreeExplorerNode> = {}
|
||||
): RenderedTreeExplorerNode {
|
||||
return {
|
||||
key: 'root',
|
||||
label: 'Root',
|
||||
leaf: false,
|
||||
children: [],
|
||||
icon: 'pi pi-folder',
|
||||
type: 'folder',
|
||||
totalLeaves: 0,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('useTreeFolderOperations', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(123)
|
||||
})
|
||||
|
||||
it('creates a temporary editable folder under the selected target', () => {
|
||||
const expandNode = vi.fn()
|
||||
const target = makeFolder({ key: 'models', handleAddFolder: vi.fn() })
|
||||
const operations = withI18n(() => useTreeFolderOperations(expandNode))
|
||||
|
||||
operations.addFolderCommand(target)
|
||||
|
||||
expect(expandNode).toHaveBeenCalledWith(target)
|
||||
expect(operations.newFolderNode.value).toMatchObject({
|
||||
key: 'models/new_folder_123',
|
||||
label: '',
|
||||
leaf: false,
|
||||
icon: 'pi pi-folder',
|
||||
type: 'folder',
|
||||
isEditingLabel: true
|
||||
})
|
||||
})
|
||||
|
||||
it('passes the confirmed name to the target and clears temporary state', async () => {
|
||||
const handleAddFolder = vi.fn()
|
||||
const target = makeFolder({ handleAddFolder })
|
||||
const operations = withI18n(() => useTreeFolderOperations(vi.fn()))
|
||||
|
||||
operations.addFolderCommand(target)
|
||||
await operations.handleFolderCreation('New Folder')
|
||||
|
||||
expect(handleAddFolder).toHaveBeenCalledWith('New Folder')
|
||||
expect(operations.newFolderNode.value).toBeNull()
|
||||
})
|
||||
|
||||
it('clears temporary state even when folder creation fails', async () => {
|
||||
const handleAddFolder = vi.fn().mockRejectedValue(new Error('failed'))
|
||||
const target = makeFolder({ handleAddFolder })
|
||||
const operations = withI18n(() => useTreeFolderOperations(vi.fn()))
|
||||
|
||||
operations.addFolderCommand(target)
|
||||
|
||||
await expect(operations.handleFolderCreation('New Folder')).rejects.toThrow(
|
||||
'failed'
|
||||
)
|
||||
expect(operations.newFolderNode.value).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores folder creation when no target is pending', async () => {
|
||||
const operations = withI18n(() => useTreeFolderOperations(vi.fn()))
|
||||
|
||||
await operations.handleFolderCreation('Unused')
|
||||
|
||||
expect(operations.newFolderNode.value).toBeNull()
|
||||
})
|
||||
|
||||
it('returns a hidden menu item when the target cannot add folders', () => {
|
||||
const operations = withI18n(() => useTreeFolderOperations(vi.fn()))
|
||||
|
||||
expect(operations.getAddFolderMenuItem(null)).toMatchObject({
|
||||
label: 'g.newFolder',
|
||||
visible: false,
|
||||
isAsync: false
|
||||
})
|
||||
expect(
|
||||
operations.getAddFolderMenuItem(makeFolder({ leaf: true }))
|
||||
).toMatchObject({ visible: false })
|
||||
expect(
|
||||
operations.getAddFolderMenuItem(makeFolder({ leaf: false }))
|
||||
).toMatchObject({ visible: false })
|
||||
})
|
||||
|
||||
it('does nothing when the menu command fires without a target', () => {
|
||||
const expandNode = vi.fn()
|
||||
const operations = withI18n(() => useTreeFolderOperations(expandNode))
|
||||
const item = operations.getAddFolderMenuItem(null)
|
||||
|
||||
expect(() =>
|
||||
item.command?.({ originalEvent: new Event('click'), item })
|
||||
).not.toThrow()
|
||||
|
||||
expect(expandNode).not.toHaveBeenCalled()
|
||||
expect(operations.newFolderNode.value).toBeNull()
|
||||
})
|
||||
|
||||
it('runs the add folder command from a visible menu item', () => {
|
||||
const expandNode = vi.fn()
|
||||
const target = makeFolder({ handleAddFolder: vi.fn() })
|
||||
const operations = withI18n(() => useTreeFolderOperations(expandNode))
|
||||
const item = operations.getAddFolderMenuItem(target)
|
||||
|
||||
expect(item.visible).toBe(true)
|
||||
item.command?.({ originalEvent: new Event('click'), item })
|
||||
|
||||
expect(expandNode).toHaveBeenCalledWith(target)
|
||||
expect(operations.newFolderNode.value?.key).toBe('root/new_folder_123')
|
||||
})
|
||||
})
|
||||
104
src/composables/useApiRequest.test.ts
Normal file
104
src/composables/useApiRequest.test.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import type { AxiosInstance, AxiosResponse } from 'axios'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useApiRequest } from '@/composables/useApiRequest'
|
||||
|
||||
const mockIsAbortError = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/utils/typeGuardUtil', () => ({
|
||||
isAbortError: mockIsAbortError
|
||||
}))
|
||||
|
||||
const client = { id: 'test-client' } as unknown as AxiosInstance
|
||||
|
||||
function response<T>(data: T): AxiosResponse<T> {
|
||||
return { data } as AxiosResponse<T>
|
||||
}
|
||||
|
||||
describe('useApiRequest', () => {
|
||||
beforeEach(() => {
|
||||
mockIsAbortError.mockReset()
|
||||
mockIsAbortError.mockReturnValue(false)
|
||||
})
|
||||
|
||||
it('returns response data and toggles loading state', async () => {
|
||||
const { isLoading, error, executeRequest } = useApiRequest({
|
||||
client,
|
||||
mapError: vi.fn()
|
||||
})
|
||||
expect(isLoading.value).toBe(false)
|
||||
|
||||
const pending = executeRequest(async () => response('ok'), {
|
||||
errorContext: 'ctx'
|
||||
})
|
||||
expect(isLoading.value).toBe(true)
|
||||
|
||||
await expect(pending).resolves.toBe('ok')
|
||||
expect(isLoading.value).toBe(false)
|
||||
expect(error.value).toBeNull()
|
||||
})
|
||||
|
||||
it('passes the injected client to the api call', async () => {
|
||||
const apiCall = vi.fn(async () => response(1))
|
||||
const { executeRequest } = useApiRequest({ client, mapError: vi.fn() })
|
||||
|
||||
await executeRequest(apiCall, { errorContext: 'ctx' })
|
||||
|
||||
expect(apiCall).toHaveBeenCalledWith(client)
|
||||
})
|
||||
|
||||
it('maps errors through the injected mapper and stores the message', async () => {
|
||||
const mapError = vi.fn(() => 'mapped message')
|
||||
const routeSpecificErrors = { 404: 'nope' }
|
||||
const boom = new Error('boom')
|
||||
const { error, executeRequest } = useApiRequest({ client, mapError })
|
||||
|
||||
const result = await executeRequest(
|
||||
() => {
|
||||
throw boom
|
||||
},
|
||||
{ errorContext: 'ctx', routeSpecificErrors }
|
||||
)
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(mapError).toHaveBeenCalledWith(boom, 'ctx', routeSpecificErrors)
|
||||
expect(error.value).toBe('mapped message')
|
||||
})
|
||||
|
||||
it('swallows cancellations without mapping an error', async () => {
|
||||
mockIsAbortError.mockReturnValue(true)
|
||||
const mapError = vi.fn(() => 'should not run')
|
||||
const { error, executeRequest } = useApiRequest({ client, mapError })
|
||||
|
||||
const result = await executeRequest(
|
||||
() => {
|
||||
throw new Error('aborted')
|
||||
},
|
||||
{ errorContext: 'ctx' }
|
||||
)
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(mapError).not.toHaveBeenCalled()
|
||||
expect(error.value).toBeNull()
|
||||
})
|
||||
|
||||
it('runs onSuccess only after a successful response', async () => {
|
||||
const onSuccess = vi.fn()
|
||||
const { executeRequest } = useApiRequest({ client, mapError: vi.fn() })
|
||||
|
||||
await executeRequest(async () => response('ok'), {
|
||||
errorContext: 'ctx',
|
||||
onSuccess
|
||||
})
|
||||
expect(onSuccess).toHaveBeenCalledTimes(1)
|
||||
|
||||
onSuccess.mockClear()
|
||||
await executeRequest(
|
||||
() => {
|
||||
throw new Error('boom')
|
||||
},
|
||||
{ errorContext: 'ctx', onSuccess }
|
||||
)
|
||||
expect(onSuccess).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
63
src/composables/useApiRequest.ts
Normal file
63
src/composables/useApiRequest.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { AxiosInstance, AxiosResponse } from 'axios'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { isAbortError } from '@/utils/typeGuardUtil'
|
||||
|
||||
/**
|
||||
* Maps a caught request error to a user-facing message string.
|
||||
* Each service injects its own mapper so it keeps control of the exact
|
||||
* status-to-message copy it presents.
|
||||
*/
|
||||
export type ApiErrorMapper = (
|
||||
err: unknown,
|
||||
errorContext: string,
|
||||
routeSpecificErrors?: Record<number, string>
|
||||
) => string
|
||||
|
||||
export interface ExecuteRequestOptions {
|
||||
errorContext: string
|
||||
routeSpecificErrors?: Record<number, string>
|
||||
/** Side effect run after a successful response, before the data is returned. */
|
||||
onSuccess?: () => unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared axios request wrapper: owns the `isLoading`/`error` state and the
|
||||
* try/catch/finally plumbing, while the caller injects the axios instance and
|
||||
* an error mapper. Cancellations are swallowed (no error set, `null` returned).
|
||||
*/
|
||||
export function useApiRequest({
|
||||
client,
|
||||
mapError
|
||||
}: {
|
||||
client: AxiosInstance
|
||||
mapError: ApiErrorMapper
|
||||
}) {
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function executeRequest<T>(
|
||||
apiCall: (client: AxiosInstance) => Promise<AxiosResponse<T>>,
|
||||
options: ExecuteRequestOptions
|
||||
): Promise<T | null> {
|
||||
const { errorContext, routeSpecificErrors, onSuccess } = options
|
||||
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const response = await apiCall(client)
|
||||
await onSuccess?.()
|
||||
return response.data
|
||||
} catch (err) {
|
||||
if (isAbortError(err)) return null
|
||||
|
||||
error.value = mapError(err, errorContext, routeSpecificErrors)
|
||||
return null
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { isLoading, error, executeRequest }
|
||||
}
|
||||
@@ -1,293 +0,0 @@
|
||||
import { ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useCanvasDrop } from '@/composables/useCanvasDrop'
|
||||
import { createMockCanvas } from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
type DropInput = {
|
||||
clientX: number
|
||||
clientY: number
|
||||
}
|
||||
|
||||
type DropEvent = {
|
||||
location: { current: { input: DropInput } }
|
||||
source: { data: { type: string; data?: unknown } }
|
||||
}
|
||||
|
||||
type DroppableOptions = {
|
||||
getDropEffect: (
|
||||
args: DropEvent
|
||||
) => Exclude<DataTransfer['dropEffect'], 'none'>
|
||||
onDrop: (event: DropEvent) => Promise<void>
|
||||
}
|
||||
|
||||
const {
|
||||
MockComfyModelDef,
|
||||
MockComfyNodeDefImpl,
|
||||
MockComfyWorkflow,
|
||||
captured,
|
||||
graph,
|
||||
insertWorkflow,
|
||||
addNodeOnGraph,
|
||||
getNodeProvider,
|
||||
getAllNodeProviders,
|
||||
withNodeAddSource
|
||||
} = vi.hoisted(() => {
|
||||
class MockComfyNodeDefImpl {
|
||||
name: string
|
||||
|
||||
constructor(name = 'NodeDef') {
|
||||
this.name = name
|
||||
}
|
||||
}
|
||||
|
||||
class MockComfyModelDef {
|
||||
directory: string
|
||||
file_name: string
|
||||
|
||||
constructor(directory = 'checkpoints', fileName = 'model.safetensors') {
|
||||
this.directory = directory
|
||||
this.file_name = fileName
|
||||
}
|
||||
}
|
||||
|
||||
class MockComfyWorkflow {
|
||||
id: string
|
||||
|
||||
constructor(id = 'workflow') {
|
||||
this.id = id
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
MockComfyModelDef,
|
||||
MockComfyNodeDefImpl,
|
||||
MockComfyWorkflow,
|
||||
captured: {
|
||||
options: undefined as DroppableOptions | undefined
|
||||
},
|
||||
graph: {
|
||||
getNodeOnPos: vi.fn()
|
||||
},
|
||||
insertWorkflow: vi.fn(),
|
||||
addNodeOnGraph: vi.fn(),
|
||||
getNodeProvider: vi.fn(),
|
||||
getAllNodeProviders: vi.fn(),
|
||||
withNodeAddSource: vi.fn((_source: string, callback: () => unknown) =>
|
||||
callback()
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// useCanvasStore is the seam; useSharedCanvasPositionConversion itself runs
|
||||
// for real so the drop position math (see useCanvasPositionConversion.test.ts
|
||||
// for its own coverage) is genuinely exercised here, not reimplemented.
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({
|
||||
getCanvas: () =>
|
||||
createMockCanvas({
|
||||
canvas: document.createElement('canvas'),
|
||||
ds: { offset: [0, 0], scale: 1 }
|
||||
})
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/usePragmaticDragAndDrop', () => ({
|
||||
usePragmaticDroppable: vi.fn(
|
||||
(_target: () => HTMLCanvasElement | null, options: DroppableOptions) => {
|
||||
captured.options = options
|
||||
}
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/litegraph/src/litegraph', () => ({
|
||||
LiteGraph: { NODE_TITLE_HEIGHT: 24 }
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry/nodeAdded/nodeAddSource', () => ({
|
||||
withNodeAddSource
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
|
||||
useWorkflowService: () => ({ insertWorkflow })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
ComfyWorkflow: MockComfyWorkflow
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: { canvas: { graph } }
|
||||
}))
|
||||
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => ({ addNodeOnGraph })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/modelStore', () => ({
|
||||
ComfyModelDef: MockComfyModelDef
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/modelToNodeStore', () => ({
|
||||
useModelToNodeStore: () => ({
|
||||
getNodeProvider,
|
||||
getAllNodeProviders
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/nodeDefStore', () => ({
|
||||
ComfyNodeDefImpl: MockComfyNodeDefImpl
|
||||
}))
|
||||
|
||||
function dropEvent(
|
||||
data: unknown,
|
||||
input: DropInput = { clientX: 20, clientY: 40 }
|
||||
) {
|
||||
return {
|
||||
location: { current: { input } },
|
||||
source: { data: { type: 'tree-explorer-node', data: { data } } }
|
||||
}
|
||||
}
|
||||
|
||||
function options() {
|
||||
useCanvasDrop(ref(document.createElement('canvas')))
|
||||
const value = captured.options
|
||||
if (!value) throw new Error('droppable options were not registered')
|
||||
return value
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
captured.options = undefined
|
||||
graph.getNodeOnPos.mockReset()
|
||||
insertWorkflow.mockReset()
|
||||
addNodeOnGraph.mockReset()
|
||||
getNodeProvider.mockReset()
|
||||
getAllNodeProviders.mockReset()
|
||||
withNodeAddSource.mockClear()
|
||||
})
|
||||
|
||||
describe('useCanvasDrop', () => {
|
||||
it('uses copy effect only for tree explorer nodes', () => {
|
||||
const droppable = options()
|
||||
|
||||
expect(
|
||||
droppable.getDropEffect({
|
||||
...dropEvent(undefined),
|
||||
source: { data: { type: 'tree-explorer-node' } }
|
||||
})
|
||||
).toBe('copy')
|
||||
expect(
|
||||
droppable.getDropEffect({
|
||||
...dropEvent(undefined),
|
||||
source: { data: { type: 'other' } }
|
||||
})
|
||||
).toBe('move')
|
||||
})
|
||||
|
||||
it('adds dropped node definitions below the cursor', async () => {
|
||||
const nodeDef = new MockComfyNodeDefImpl('KSampler')
|
||||
const droppable = options()
|
||||
|
||||
await droppable.onDrop(dropEvent(nodeDef))
|
||||
|
||||
expect(withNodeAddSource).toHaveBeenCalledWith(
|
||||
'sidebar_drag',
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(addNodeOnGraph).toHaveBeenCalledWith(nodeDef, {
|
||||
pos: [20, 64]
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores drops that do not come from tree explorer nodes', async () => {
|
||||
const nodeDef = new MockComfyNodeDefImpl('KSampler')
|
||||
const droppable = options()
|
||||
|
||||
await droppable.onDrop({
|
||||
...dropEvent(nodeDef),
|
||||
source: { data: { type: 'other', data: { data: nodeDef } } }
|
||||
})
|
||||
|
||||
expect(addNodeOnGraph).not.toHaveBeenCalled()
|
||||
expect(insertWorkflow).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sets a model widget on an existing compatible node', async () => {
|
||||
const widget = { name: 'ckpt_name', value: '' }
|
||||
const node = { comfyClass: 'CheckpointLoaderSimple', widgets: [widget] }
|
||||
const provider = {
|
||||
key: 'ckpt_name',
|
||||
nodeDef: { name: 'CheckpointLoaderSimple' }
|
||||
}
|
||||
graph.getNodeOnPos.mockReturnValue(node)
|
||||
getAllNodeProviders.mockReturnValue([provider])
|
||||
const droppable = options()
|
||||
|
||||
await droppable.onDrop(
|
||||
dropEvent(new MockComfyModelDef('checkpoints', 'dream.safetensors'))
|
||||
)
|
||||
|
||||
expect(widget.value).toBe('dream.safetensors')
|
||||
expect(addNodeOnGraph).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('creates a provider node when the model has no compatible target', async () => {
|
||||
const widget = { name: 'lora_name', value: '' }
|
||||
const createdNode = { widgets: [widget] }
|
||||
const provider = { key: 'lora_name', nodeDef: { name: 'LoraLoader' } }
|
||||
graph.getNodeOnPos.mockReturnValue(undefined)
|
||||
getNodeProvider.mockReturnValue(provider)
|
||||
addNodeOnGraph.mockReturnValue(createdNode)
|
||||
const droppable = options()
|
||||
|
||||
await droppable.onDrop(
|
||||
dropEvent(new MockComfyModelDef('loras', 'style.safetensors'))
|
||||
)
|
||||
|
||||
expect(addNodeOnGraph).toHaveBeenCalledWith(provider.nodeDef, {
|
||||
pos: [20, 40]
|
||||
})
|
||||
expect(widget.value).toBe('style.safetensors')
|
||||
})
|
||||
|
||||
it('does nothing for model drops without a compatible or default provider', async () => {
|
||||
graph.getNodeOnPos.mockReturnValue({ comfyClass: 'OtherNode' })
|
||||
getAllNodeProviders.mockReturnValue([
|
||||
{ key: 'ckpt_name', nodeDef: { name: 'CheckpointLoaderSimple' } }
|
||||
])
|
||||
getNodeProvider.mockReturnValue(null)
|
||||
const droppable = options()
|
||||
|
||||
await droppable.onDrop(
|
||||
dropEvent(new MockComfyModelDef('checkpoints', 'dream.safetensors'))
|
||||
)
|
||||
|
||||
expect(addNodeOnGraph).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not set a model value when the target node lacks the provider widget', async () => {
|
||||
const provider = { key: 'lora_name', nodeDef: { name: 'LoraLoader' } }
|
||||
const createdNode = { widgets: [{ name: 'other', value: '' }] }
|
||||
graph.getNodeOnPos.mockReturnValue(undefined)
|
||||
getNodeProvider.mockReturnValue(provider)
|
||||
addNodeOnGraph.mockReturnValue(createdNode)
|
||||
const droppable = options()
|
||||
|
||||
await droppable.onDrop(
|
||||
dropEvent(new MockComfyModelDef('loras', 'style.safetensors'))
|
||||
)
|
||||
|
||||
expect(createdNode.widgets[0].value).toBe('')
|
||||
})
|
||||
|
||||
it('inserts dropped workflows at the canvas position', async () => {
|
||||
const workflow = new MockComfyWorkflow('wf-1')
|
||||
const droppable = options()
|
||||
|
||||
await droppable.onDrop(dropEvent(workflow))
|
||||
|
||||
expect(insertWorkflow).toHaveBeenCalledWith(workflow, {
|
||||
position: [20, 40]
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,245 +0,0 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { IContextMenuOptions } from '@/lib/litegraph/src/interfaces'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphCanvas, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
const mockInstall = vi.hoisted(() => vi.fn())
|
||||
const mockRegisterWrapper = vi.hoisted(() => vi.fn())
|
||||
const mockExtractLegacyItems = vi.hoisted(() => vi.fn())
|
||||
const mockCollectCanvasMenuItems = vi.hoisted(() => vi.fn())
|
||||
const mockCollectNodeMenuItems = vi.hoisted(() => vi.fn())
|
||||
const mockSt = vi.hoisted(() => vi.fn())
|
||||
const mockTe = vi.hoisted(() => vi.fn())
|
||||
const mockContextMenuConstructor = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/lib/litegraph/src/contextMenuCompat', () => ({
|
||||
legacyMenuCompat: {
|
||||
install: mockInstall,
|
||||
registerWrapper: mockRegisterWrapper,
|
||||
extractLegacyItems: mockExtractLegacyItems
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
collectCanvasMenuItems: mockCollectCanvasMenuItems,
|
||||
collectNodeMenuItems: mockCollectNodeMenuItems
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
st: mockSt,
|
||||
te: mockTe
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/formatUtil', () => ({
|
||||
normalizeI18nKey: (value: string) => `normalized-${value}`
|
||||
}))
|
||||
|
||||
import { useContextMenuTranslation } from './useContextMenuTranslation'
|
||||
|
||||
class FakeContextMenu {
|
||||
constructor(values: unknown, options: unknown) {
|
||||
mockContextMenuConstructor(values, options)
|
||||
}
|
||||
}
|
||||
|
||||
describe('useContextMenuTranslation', () => {
|
||||
const originalGetCanvasMenuOptions =
|
||||
LGraphCanvas.prototype.getCanvasMenuOptions
|
||||
const originalGetNodeMenuOptions = LGraphCanvas.prototype.getNodeMenuOptions
|
||||
const originalContextMenu = LiteGraph.ContextMenu
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCollectCanvasMenuItems.mockReturnValue([{ content: 'NewApi' }])
|
||||
mockCollectNodeMenuItems.mockReturnValue([{ content: 'NodeApi' }])
|
||||
mockExtractLegacyItems.mockReturnValue([{ content: 'Legacy' }])
|
||||
mockSt.mockImplementation((_key: string, fallback: string) => {
|
||||
return `translated:${fallback}`
|
||||
})
|
||||
mockTe.mockImplementation(
|
||||
(key: string) => key === 'contextMenu.TranslateMe'
|
||||
)
|
||||
// Real LGraphCanvas.prototype so the composable's monkeypatch is
|
||||
// exercised against the class it patches in production; the base
|
||||
// implementations are stubbed since a real one needs a constructed
|
||||
// canvas/graph pair that this translation-only suite doesn't need.
|
||||
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
|
||||
return [{ content: 'Base' }, null]
|
||||
}
|
||||
LGraphCanvas.prototype.getNodeMenuOptions = function (node: unknown) {
|
||||
return [{ content: `Node:${String(node)}` }]
|
||||
}
|
||||
LiteGraph.ContextMenu =
|
||||
FakeContextMenu as unknown as typeof LiteGraph.ContextMenu
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
LGraphCanvas.prototype.getCanvasMenuOptions = originalGetCanvasMenuOptions
|
||||
LGraphCanvas.prototype.getNodeMenuOptions = originalGetNodeMenuOptions
|
||||
LiteGraph.ContextMenu = originalContextMenu
|
||||
})
|
||||
|
||||
it('wraps canvas menu options with new API, legacy, and translated items', () => {
|
||||
useContextMenuTranslation()
|
||||
// A plain receiver, not `instanceof LGraphCanvas`: the real class's
|
||||
// other getters (e.g. read_only) read constructor-initialized state
|
||||
// that a bare receiver doesn't have, which trips up Vitest's assertion
|
||||
// formatting. The patched method only needs a `this` to call through.
|
||||
const canvas = fromPartial<LGraphCanvas>({})
|
||||
|
||||
const result = LGraphCanvas.prototype.getCanvasMenuOptions.call(canvas)
|
||||
|
||||
// toBe, not toHaveBeenCalledWith: the real prototype's other getters
|
||||
// (e.g. read_only) read constructor-initialized state, and Vitest's
|
||||
// deep-equality formatting walks own properties, including those
|
||||
// getters, when comparing objects.
|
||||
expect(mockInstall.mock.calls[0]?.[0]).toBe(LGraphCanvas.prototype)
|
||||
expect(mockInstall.mock.calls[0]?.[1]).toBe('getCanvasMenuOptions')
|
||||
expect(mockCollectCanvasMenuItems).toHaveBeenCalledWith(canvas)
|
||||
expect(mockExtractLegacyItems).toHaveBeenCalledWith(
|
||||
'getCanvasMenuOptions',
|
||||
canvas
|
||||
)
|
||||
expect(result).toEqual([
|
||||
{ content: 'translated:Base' },
|
||||
null,
|
||||
{ content: 'translated:NewApi' },
|
||||
{ content: 'translated:Legacy' }
|
||||
])
|
||||
})
|
||||
|
||||
it('wraps node menu options with new API and legacy extension items', () => {
|
||||
useContextMenuTranslation()
|
||||
const canvas = fromPartial<LGraphCanvas>({})
|
||||
const node = fromPartial<LGraphNode>({})
|
||||
|
||||
const result = LGraphCanvas.prototype.getNodeMenuOptions.call(canvas, node)
|
||||
|
||||
expect(mockInstall.mock.calls[1]?.[0]).toBe(LGraphCanvas.prototype)
|
||||
expect(mockInstall.mock.calls[1]?.[1]).toBe('getNodeMenuOptions')
|
||||
expect(mockCollectNodeMenuItems).toHaveBeenCalledWith(node)
|
||||
expect(mockExtractLegacyItems).toHaveBeenCalledWith(
|
||||
'getNodeMenuOptions',
|
||||
canvas,
|
||||
node
|
||||
)
|
||||
expect(result).toEqual([
|
||||
{ content: `Node:${String(node)}` },
|
||||
{ content: 'NodeApi' },
|
||||
{ content: 'Legacy' }
|
||||
])
|
||||
})
|
||||
|
||||
it('translates LiteGraph context menu titles, nested items, and conversion labels', () => {
|
||||
useContextMenuTranslation()
|
||||
const values = [
|
||||
{ content: 'TranslateMe' },
|
||||
{ content: 'Convert seed to input' },
|
||||
{ content: 'Convert value to widget' },
|
||||
{
|
||||
content: '',
|
||||
submenu: {
|
||||
options: [{ content: 'TranslateMe' }]
|
||||
}
|
||||
},
|
||||
'separator'
|
||||
]
|
||||
const options = {
|
||||
title: 'KSampler',
|
||||
extra: {
|
||||
inputs: [{ name: 'seed', label: 'Seed Label' }],
|
||||
widgets: [{ name: 'value', label: 'Value Label' }]
|
||||
}
|
||||
}
|
||||
|
||||
new LiteGraph.ContextMenu(values, options)
|
||||
|
||||
expect(options.title).toBe('translated:KSampler')
|
||||
expect(values).toMatchObject([
|
||||
{ content: 'translated:TranslateMe' },
|
||||
{ content: 'translated:Convert Seed Labeltranslated: to input' },
|
||||
{ content: 'translated:Convert Value Labeltranslated: to widget' },
|
||||
{
|
||||
submenu: {
|
||||
options: [{ content: 'translated:TranslateMe' }]
|
||||
}
|
||||
},
|
||||
'separator'
|
||||
])
|
||||
expect(mockContextMenuConstructor).toHaveBeenCalledWith(values, options)
|
||||
})
|
||||
|
||||
it('uses parent menu extra data when direct options do not provide it', () => {
|
||||
useContextMenuTranslation()
|
||||
const values = [{ content: 'Convert latent to input' }]
|
||||
const options = {
|
||||
parentMenu: {
|
||||
options: {
|
||||
extra: {
|
||||
inputs: [{ name: 'latent', label: 'Latent Label' }]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
new LiteGraph.ContextMenu(
|
||||
values,
|
||||
fromPartial<IContextMenuOptions<unknown, unknown>>(options)
|
||||
)
|
||||
|
||||
expect(values[0].content).toBe(
|
||||
'translated:Convert Latent Labeltranslated: to input'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps conversion names when matching inputs and widgets have no label', () => {
|
||||
useContextMenuTranslation()
|
||||
const values = [
|
||||
{ content: 'Convert seed to input' },
|
||||
{ content: 'Convert value to widget' }
|
||||
]
|
||||
const options = {
|
||||
extra: {
|
||||
inputs: [{ name: 'seed' }],
|
||||
widgets: [{ name: 'value' }]
|
||||
}
|
||||
}
|
||||
|
||||
new LiteGraph.ContextMenu(values, options)
|
||||
|
||||
expect(values).toMatchObject([
|
||||
{ content: 'translated:Convert seedtranslated: to input' },
|
||||
{ content: 'translated:Convert valuetranslated: to widget' }
|
||||
])
|
||||
})
|
||||
|
||||
it('uses widget labels when input conversion names do not match inputs', () => {
|
||||
useContextMenuTranslation()
|
||||
const values = [{ content: 'Convert seed to input' }]
|
||||
const options = {
|
||||
extra: {
|
||||
inputs: [{ name: 'other' }],
|
||||
widgets: [{ name: 'seed', label: 'Widget Seed' }]
|
||||
}
|
||||
}
|
||||
|
||||
new LiteGraph.ContextMenu(values, options)
|
||||
|
||||
expect(values[0].content).toBe(
|
||||
'translated:Convert Widget Seedtranslated: to input'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps plain unregistered menu items unchanged', () => {
|
||||
useContextMenuTranslation()
|
||||
const values = [{ content: 'Plain' }]
|
||||
|
||||
new LiteGraph.ContextMenu(values, {})
|
||||
|
||||
expect(values[0].content).toBe('Plain')
|
||||
})
|
||||
})
|
||||
@@ -1,125 +0,0 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { KeybindingImpl } from '@/platform/keybindings/keybinding'
|
||||
import type { KeyComboImpl } from '@/platform/keybindings/keyCombo'
|
||||
|
||||
import { DIALOG_KEY, useEditKeybindingDialog } from './useEditKeybindingDialog'
|
||||
|
||||
const mockShowSmallLayoutDialog = vi.fn()
|
||||
const mockGetKeybinding = vi.fn()
|
||||
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
useDialogService: () => ({
|
||||
showSmallLayoutDialog: mockShowSmallLayoutDialog
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/keybindings/keybindingStore', () => ({
|
||||
useKeybindingStore: () => ({
|
||||
getKeybinding: mockGetKeybinding
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/components/dialog/content/setting/keybinding/EditKeybindingContent.vue',
|
||||
() => ({
|
||||
default: { name: 'EditKeybindingContent' }
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock(
|
||||
'@/components/dialog/content/setting/keybinding/EditKeybindingFooter.vue',
|
||||
() => ({
|
||||
default: { name: 'EditKeybindingFooter' }
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock(
|
||||
'@/components/dialog/content/setting/keybinding/EditKeybindingHeader.vue',
|
||||
() => ({
|
||||
default: { name: 'EditKeybindingHeader' }
|
||||
})
|
||||
)
|
||||
|
||||
function makeCombo(label: string): KeyComboImpl {
|
||||
const combo: unknown = {
|
||||
label,
|
||||
equals: vi.fn((other: { label: string }) => other.label === label)
|
||||
}
|
||||
return combo as KeyComboImpl
|
||||
}
|
||||
|
||||
describe('useEditKeybindingDialog', () => {
|
||||
it('opens the edit dialog with default edit state', () => {
|
||||
const currentCombo = makeCombo('Ctrl+A')
|
||||
|
||||
useEditKeybindingDialog().show({
|
||||
commandId: 'app.test',
|
||||
commandLabel: 'Test command',
|
||||
currentCombo
|
||||
})
|
||||
|
||||
expect(mockShowSmallLayoutDialog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
key: DIALOG_KEY,
|
||||
props: expect.objectContaining({
|
||||
commandLabel: 'Test command'
|
||||
})
|
||||
})
|
||||
)
|
||||
const dialog = mockShowSmallLayoutDialog.mock.calls[0][0]
|
||||
expect(dialog.props.dialogState).toMatchObject({
|
||||
commandId: 'app.test',
|
||||
newCombo: currentCombo,
|
||||
currentCombo,
|
||||
mode: 'edit',
|
||||
existingBinding: null
|
||||
})
|
||||
expect(dialog.footerProps.dialogState).toBe(dialog.props.dialogState)
|
||||
})
|
||||
|
||||
it('updates combo state and reports a conflicting binding', () => {
|
||||
const currentCombo = makeCombo('Ctrl+A')
|
||||
const newCombo = makeCombo('Ctrl+B')
|
||||
const binding = fromPartial<KeybindingImpl>({
|
||||
commandId: 'other.command'
|
||||
})
|
||||
mockGetKeybinding.mockReturnValue(binding)
|
||||
|
||||
useEditKeybindingDialog().show({
|
||||
commandId: 'app.test',
|
||||
commandLabel: 'Test command',
|
||||
currentCombo,
|
||||
mode: 'add',
|
||||
existingBinding: binding
|
||||
})
|
||||
|
||||
const dialog = mockShowSmallLayoutDialog.mock.calls.at(-1)![0]
|
||||
dialog.props.onUpdateCombo(newCombo)
|
||||
|
||||
expect(dialog.props.dialogState.newCombo).toMatchObject({
|
||||
label: 'Ctrl+B'
|
||||
})
|
||||
expect(dialog.props.existingKeybindingOnCombo.value).toBe(binding)
|
||||
expect(mockGetKeybinding.mock.calls.at(-1)?.[0]).toMatchObject({
|
||||
label: 'Ctrl+B'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not report a conflict for an unchanged or empty combo', () => {
|
||||
const currentCombo = makeCombo('Ctrl+A')
|
||||
|
||||
useEditKeybindingDialog().show({
|
||||
commandId: 'app.test',
|
||||
commandLabel: 'Test command',
|
||||
currentCombo
|
||||
})
|
||||
|
||||
const dialog = mockShowSmallLayoutDialog.mock.calls.at(-1)![0]
|
||||
|
||||
expect(dialog.props.existingKeybindingOnCombo.value).toBeNull()
|
||||
dialog.props.dialogState.newCombo = null
|
||||
expect(dialog.props.existingKeybindingOnCombo.value).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -30,14 +30,6 @@ vi.mock('@/platform/distribution/types', () => ({
|
||||
describe('useFeatureFlags', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.unstubAllEnvs()
|
||||
localStorage.clear()
|
||||
remoteConfig.value = {}
|
||||
remoteConfigState.value = 'unloaded'
|
||||
cachedTeamWorkspacesEnabled.value = undefined
|
||||
cachedConsolidatedBillingEnabled.value = undefined
|
||||
vi.mocked(distributionTypes).isCloud = false
|
||||
vi.mocked(distributionTypes).isNightly = false
|
||||
})
|
||||
|
||||
describe('flags object', () => {
|
||||
@@ -234,69 +226,6 @@ describe('useFeatureFlags', () => {
|
||||
expect(flags.teamWorkspacesEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it('teamWorkspacesEnabled is disabled outside cloud builds', () => {
|
||||
vi.mocked(distributionTypes).isCloud = false
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.teamWorkspacesEnabled).toBe(false)
|
||||
})
|
||||
|
||||
it('teamWorkspacesEnabled uses the cached value before authenticated config loads', () => {
|
||||
vi.mocked(distributionTypes).isCloud = true
|
||||
cachedTeamWorkspacesEnabled.value = true
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.teamWorkspacesEnabled).toBe(true)
|
||||
expect(api.getServerFeature).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('teamWorkspacesEnabled falls back to the server after authenticated config loads', () => {
|
||||
vi.mocked(distributionTypes).isCloud = true
|
||||
remoteConfigState.value = 'authenticated'
|
||||
vi.mocked(api.getServerFeature).mockImplementation(
|
||||
(path, defaultValue) => {
|
||||
if (path === ServerFeatureFlag.TEAM_WORKSPACES_ENABLED) return true
|
||||
return defaultValue
|
||||
}
|
||||
)
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.teamWorkspacesEnabled).toBe(true)
|
||||
expect(api.getServerFeature).toHaveBeenCalledWith(
|
||||
ServerFeatureFlag.TEAM_WORKSPACES_ENABLED,
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('nodeLibraryEssentialsEnabled checks config outside nightly and dev builds', () => {
|
||||
vi.stubEnv('DEV', false)
|
||||
vi.mocked(api.getServerFeature).mockImplementation(
|
||||
(path, defaultValue) => {
|
||||
if (path === ServerFeatureFlag.NODE_LIBRARY_ESSENTIALS_ENABLED)
|
||||
return true
|
||||
return defaultValue
|
||||
}
|
||||
)
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.nodeLibraryEssentialsEnabled).toBe(true)
|
||||
expect(api.getServerFeature).toHaveBeenCalledWith(
|
||||
ServerFeatureFlag.NODE_LIBRARY_ESSENTIALS_ENABLED,
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('nodeLibraryEssentialsEnabled uses remote config before the server fallback', () => {
|
||||
vi.stubEnv('DEV', false)
|
||||
remoteConfig.value = {
|
||||
node_library_essentials_enabled: true
|
||||
}
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.nodeLibraryEssentialsEnabled).toBe(true)
|
||||
expect(api.getServerFeature).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('consolidatedBillingEnabled override bypasses isCloud and isAuthenticatedConfigLoaded guards', () => {
|
||||
vi.mocked(distributionTypes).isCloud = false
|
||||
localStorage.setItem('ff:consolidated_billing_enabled', 'true')
|
||||
|
||||
@@ -1,588 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, defineComponent, nextTick } from 'vue'
|
||||
import type { App } from 'vue'
|
||||
|
||||
const mockStats = {
|
||||
min: 0,
|
||||
max: 4,
|
||||
mean: 1,
|
||||
stdDev: 0.5,
|
||||
nanCount: 0,
|
||||
infCount: 0
|
||||
}
|
||||
const mockHistograms = {
|
||||
r: new Uint32Array([1]),
|
||||
g: new Uint32Array([2]),
|
||||
b: new Uint32Array([3]),
|
||||
a: new Uint32Array([4]),
|
||||
luminance: new Uint32Array([5])
|
||||
}
|
||||
|
||||
interface MockTexture {
|
||||
type: string
|
||||
image: {
|
||||
width: number
|
||||
height: number
|
||||
data: ArrayLike<number>
|
||||
}
|
||||
colorSpace?: string
|
||||
minFilter?: string
|
||||
magFilter?: string
|
||||
needsUpdate?: boolean
|
||||
dispose: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
exrLoad: vi.fn(),
|
||||
exrSetDataType: vi.fn(),
|
||||
rgbeLoad: vi.fn(),
|
||||
render: vi.fn(),
|
||||
setPixelRatio: vi.fn(),
|
||||
setClearColor: vi.fn(),
|
||||
setSize: vi.fn(),
|
||||
updateProjectionMatrix: vi.fn(),
|
||||
positionSet: vi.fn(),
|
||||
scaleSet: vi.fn(),
|
||||
sceneAdd: vi.fn(),
|
||||
materialDispose: vi.fn(),
|
||||
geometryDispose: vi.fn(),
|
||||
viewportObserveResize: vi.fn(),
|
||||
viewportDisposeRenderer: vi.fn(),
|
||||
textureDispose: vi.fn(),
|
||||
raycasterSetFromCamera: vi.fn(),
|
||||
intersectObject: vi.fn(),
|
||||
fromHalfFloat: vi.fn((value: number) => value + 0.5),
|
||||
matrixSet: vi.fn(),
|
||||
detectGamutFromChromaticities: vi.fn(() => 'Display P3'),
|
||||
gamutToSrgbMatrix: vi.fn(() => [1, 0, 0, 0, 1, 0, 0, 0, 1]),
|
||||
computeImageStats: vi.fn(() => mockStats),
|
||||
computeChannelHistograms: vi.fn(() => mockHistograms),
|
||||
lastCanvas: undefined as HTMLCanvasElement | undefined
|
||||
}))
|
||||
|
||||
vi.mock('three', () => {
|
||||
class WebGLRenderer {
|
||||
domElement: HTMLCanvasElement
|
||||
outputColorSpace?: string
|
||||
|
||||
constructor() {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.getBoundingClientRect = () =>
|
||||
({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
right: 100,
|
||||
bottom: 100,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({})
|
||||
}) satisfies DOMRect
|
||||
this.domElement = canvas
|
||||
mocks.lastCanvas = canvas
|
||||
}
|
||||
|
||||
setPixelRatio(value: number) {
|
||||
mocks.setPixelRatio(value)
|
||||
}
|
||||
|
||||
setClearColor(color: number, alpha: number) {
|
||||
mocks.setClearColor(color, alpha)
|
||||
}
|
||||
|
||||
setSize(width: number, height: number, updateStyle: boolean) {
|
||||
mocks.setSize(width, height, updateStyle)
|
||||
}
|
||||
|
||||
render(scene: unknown, camera: unknown) {
|
||||
mocks.render(scene, camera)
|
||||
}
|
||||
}
|
||||
|
||||
class Scene {
|
||||
add(mesh: unknown) {
|
||||
mocks.sceneAdd(mesh)
|
||||
}
|
||||
}
|
||||
|
||||
class OrthographicCamera {
|
||||
left = -1
|
||||
right = 1
|
||||
top = 1
|
||||
bottom = -1
|
||||
zoom = 1
|
||||
position = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: 1,
|
||||
set: (x: number, y: number, z: number) => {
|
||||
this.position.x = x
|
||||
this.position.y = y
|
||||
this.position.z = z
|
||||
mocks.positionSet(x, y, z)
|
||||
}
|
||||
}
|
||||
|
||||
updateProjectionMatrix() {
|
||||
mocks.updateProjectionMatrix()
|
||||
}
|
||||
}
|
||||
|
||||
class Matrix3 {
|
||||
set(...values: number[]) {
|
||||
mocks.matrixSet(values)
|
||||
}
|
||||
}
|
||||
|
||||
class ShaderMaterial {
|
||||
uniforms: Record<string, { value: unknown }>
|
||||
|
||||
constructor(options: { uniforms: Record<string, { value: unknown }> }) {
|
||||
this.uniforms = options.uniforms
|
||||
}
|
||||
|
||||
dispose() {
|
||||
mocks.materialDispose()
|
||||
}
|
||||
}
|
||||
|
||||
class Mesh {
|
||||
scale = { set: mocks.scaleSet }
|
||||
geometry = { dispose: mocks.geometryDispose }
|
||||
|
||||
constructor(
|
||||
readonly geometryInput: unknown,
|
||||
readonly materialInput: unknown
|
||||
) {}
|
||||
}
|
||||
|
||||
class PlaneGeometry {
|
||||
constructor(
|
||||
readonly width: number,
|
||||
readonly height: number
|
||||
) {}
|
||||
}
|
||||
|
||||
class Raycaster {
|
||||
setFromCamera(pointer: unknown, camera: unknown) {
|
||||
mocks.raycasterSetFromCamera(pointer, camera)
|
||||
}
|
||||
|
||||
intersectObject(mesh: unknown) {
|
||||
return mocks.intersectObject(mesh)
|
||||
}
|
||||
}
|
||||
|
||||
class Vector2 {
|
||||
constructor(
|
||||
public x = 0,
|
||||
public y = 0
|
||||
) {}
|
||||
}
|
||||
|
||||
return {
|
||||
WebGLRenderer,
|
||||
Scene,
|
||||
OrthographicCamera,
|
||||
Matrix3,
|
||||
ShaderMaterial,
|
||||
Mesh,
|
||||
PlaneGeometry,
|
||||
Raycaster,
|
||||
Vector2,
|
||||
DataUtils: { fromHalfFloat: mocks.fromHalfFloat },
|
||||
MathUtils: {
|
||||
clamp: (value: number, min: number, max: number) =>
|
||||
Math.min(max, Math.max(min, value))
|
||||
},
|
||||
FloatType: 'FloatType',
|
||||
HalfFloatType: 'HalfFloatType',
|
||||
LinearSRGBColorSpace: 'LinearSRGBColorSpace',
|
||||
LinearFilter: 'LinearFilter',
|
||||
GLSL3: 'GLSL3'
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('three/examples/jsm/loaders/EXRLoader', () => ({
|
||||
EXRLoader: class {
|
||||
setDataType(type: string) {
|
||||
mocks.exrSetDataType(type)
|
||||
}
|
||||
|
||||
load(
|
||||
url: string,
|
||||
onLoad: (texture: MockTexture, textureData?: unknown) => void,
|
||||
onProgress: unknown,
|
||||
onError: (error: unknown) => void
|
||||
) {
|
||||
mocks.exrLoad(url, onLoad, onProgress, onError)
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('three/examples/jsm/loaders/RGBELoader', () => ({
|
||||
RGBELoader: class {
|
||||
load(
|
||||
url: string,
|
||||
onLoad: (texture: MockTexture, textureData?: unknown) => void,
|
||||
onProgress: unknown,
|
||||
onError: (error: unknown) => void
|
||||
) {
|
||||
mocks.rgbeLoad(url, onLoad, onProgress, onError)
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/three/WebGLViewport', () => ({
|
||||
WebGLViewport: class {
|
||||
constructor(readonly renderer: unknown) {}
|
||||
|
||||
observeResize(container: HTMLElement, resize: () => void) {
|
||||
mocks.viewportObserveResize(container, resize)
|
||||
}
|
||||
|
||||
disposeRenderer() {
|
||||
mocks.viewportDisposeRenderer()
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/hdr/colorGamut', () => ({
|
||||
detectGamutFromChromaticities: mocks.detectGamutFromChromaticities,
|
||||
gamutToSrgbMatrix: mocks.gamutToSrgbMatrix
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/hdr/hdrStats', () => ({
|
||||
computeImageStats: mocks.computeImageStats,
|
||||
computeChannelHistograms: mocks.computeChannelHistograms
|
||||
}))
|
||||
|
||||
import { useHdrViewer } from './useHdrViewer'
|
||||
|
||||
type HdrViewer = ReturnType<typeof useHdrViewer>
|
||||
|
||||
let mountedApps: App[] = []
|
||||
|
||||
function createViewer(): HdrViewer {
|
||||
let viewer: HdrViewer | undefined
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup() {
|
||||
viewer = useHdrViewer()
|
||||
return () => null
|
||||
}
|
||||
})
|
||||
)
|
||||
app.mount(document.createElement('div'))
|
||||
mountedApps.push(app)
|
||||
if (!viewer) throw new Error('Expected useHdrViewer to initialize')
|
||||
return viewer
|
||||
}
|
||||
|
||||
function makeTexture(
|
||||
data: ArrayLike<number> = [0, 0.25, 0.5, 1, 1, 2, 3, 4],
|
||||
width = 2,
|
||||
height = 1,
|
||||
type = 'FloatType'
|
||||
): MockTexture {
|
||||
return {
|
||||
type,
|
||||
image: { width, height, data },
|
||||
dispose: mocks.textureDispose
|
||||
}
|
||||
}
|
||||
|
||||
function makeContainer(): HTMLElement {
|
||||
const container = document.createElement('div')
|
||||
Object.defineProperty(container, 'clientWidth', { value: 200 })
|
||||
Object.defineProperty(container, 'clientHeight', { value: 100 })
|
||||
return container
|
||||
}
|
||||
|
||||
describe('useHdrViewer', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
callback(0)
|
||||
return 1
|
||||
})
|
||||
mocks.lastCanvas = undefined
|
||||
mocks.exrLoad.mockImplementation(
|
||||
(
|
||||
_url: string,
|
||||
onLoad: (texture: MockTexture, textureData?: unknown) => void
|
||||
) => onLoad(makeTexture())
|
||||
)
|
||||
mocks.rgbeLoad.mockImplementation(
|
||||
(
|
||||
_url: string,
|
||||
onLoad: (texture: MockTexture, textureData?: unknown) => void
|
||||
) => onLoad(makeTexture(), { header: { chromaticities: {} } })
|
||||
)
|
||||
mocks.intersectObject.mockReturnValue([])
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const app of mountedApps) app.unmount()
|
||||
mountedApps = []
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('mounts hdr textures through the RGBE loader and exposes image metadata', async () => {
|
||||
const viewer = createViewer()
|
||||
const container = makeContainer()
|
||||
|
||||
await viewer.mount(container, '/api/view?filename=scene.hdr')
|
||||
|
||||
expect(mocks.rgbeLoad).toHaveBeenCalledWith(
|
||||
'/api/view?filename=scene.hdr',
|
||||
expect.any(Function),
|
||||
undefined,
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(mocks.exrSetDataType).not.toHaveBeenCalled()
|
||||
expect(viewer.loading.value).toBe(false)
|
||||
expect(viewer.error.value).toBeNull()
|
||||
expect(viewer.gamut.value).toBe('Display P3')
|
||||
expect(viewer.dimensions.value).toBe('2 x 1')
|
||||
expect(viewer.stats.value).toEqual(mockStats)
|
||||
expect(viewer.histogram.value).toBe(mockHistograms.luminance)
|
||||
viewer.channel.value = 'g'
|
||||
await nextTick()
|
||||
expect(viewer.histogram.value).toBe(mockHistograms.g)
|
||||
expect(container.contains(mocks.lastCanvas!)).toBe(true)
|
||||
})
|
||||
|
||||
it('selects histograms for every channel mode', async () => {
|
||||
const viewer = createViewer()
|
||||
|
||||
await viewer.mount(makeContainer(), '/api/view?filename=scene.hdr')
|
||||
|
||||
for (const [mode, histogram] of [
|
||||
['r', mockHistograms.r],
|
||||
['g', mockHistograms.g],
|
||||
['b', mockHistograms.b],
|
||||
['a', mockHistograms.a],
|
||||
['rgb', mockHistograms.luminance],
|
||||
['luminance', mockHistograms.luminance]
|
||||
] as const) {
|
||||
viewer.channel.value = mode
|
||||
await nextTick()
|
||||
expect(viewer.histogram.value).toBe(histogram)
|
||||
}
|
||||
})
|
||||
|
||||
it('loads exr textures with float data and reads hovered pixels', async () => {
|
||||
const data = [0, 1, 2, 3, 4, 5, 6, 7]
|
||||
mocks.exrLoad.mockImplementation(
|
||||
(
|
||||
_url: string,
|
||||
onLoad: (texture: MockTexture, textureData?: unknown) => void
|
||||
) => onLoad(makeTexture(data, 2, 1, 'HalfFloatType'))
|
||||
)
|
||||
mocks.intersectObject.mockReturnValue([{ uv: { x: 0.75, y: 0.25 } }])
|
||||
const viewer = createViewer()
|
||||
|
||||
await viewer.mount(makeContainer(), '/api/view?filename=scene.exr')
|
||||
mocks.lastCanvas!.dispatchEvent(
|
||||
new PointerEvent('pointermove', { clientX: 75, clientY: 75 })
|
||||
)
|
||||
|
||||
expect(mocks.exrSetDataType).toHaveBeenCalledWith('FloatType')
|
||||
expect(mocks.fromHalfFloat).toHaveBeenCalled()
|
||||
expect(viewer.pixel.value).toEqual({
|
||||
x: 1,
|
||||
y: 0,
|
||||
r: 4.5,
|
||||
g: 5.5,
|
||||
b: 6.5,
|
||||
a: 7.5
|
||||
})
|
||||
})
|
||||
|
||||
it('reads three-channel pixels without alpha', async () => {
|
||||
mocks.exrLoad.mockImplementation(
|
||||
(
|
||||
_url: string,
|
||||
onLoad: (texture: MockTexture, textureData?: unknown) => void
|
||||
) => onLoad(makeTexture([0, 1, 2, 3, 4, 5], 2, 1))
|
||||
)
|
||||
mocks.intersectObject.mockReturnValue([{ uv: { x: 0.75, y: 0.25 } }])
|
||||
const viewer = createViewer()
|
||||
|
||||
await viewer.mount(makeContainer(), '/api/view?filename=scene.exr')
|
||||
mocks.lastCanvas!.dispatchEvent(
|
||||
new PointerEvent('pointermove', { clientX: 75, clientY: 75 })
|
||||
)
|
||||
|
||||
expect(viewer.pixel.value).toEqual({
|
||||
x: 1,
|
||||
y: 0,
|
||||
r: 3,
|
||||
g: 4,
|
||||
b: 5,
|
||||
a: null
|
||||
})
|
||||
})
|
||||
|
||||
it('clears the hovered pixel when the pointer leaves or misses the mesh', async () => {
|
||||
mocks.intersectObject.mockReturnValueOnce([{ uv: { x: 0, y: 0 } }])
|
||||
const viewer = createViewer()
|
||||
|
||||
await viewer.mount(makeContainer(), '/api/view?filename=scene.exr')
|
||||
mocks.lastCanvas!.dispatchEvent(new PointerEvent('pointermove'))
|
||||
expect(viewer.pixel.value).not.toBeNull()
|
||||
|
||||
mocks.lastCanvas!.dispatchEvent(new PointerEvent('pointerleave'))
|
||||
expect(viewer.pixel.value).toBeNull()
|
||||
|
||||
mocks.lastCanvas!.dispatchEvent(new PointerEvent('pointermove'))
|
||||
expect(viewer.pixel.value).toBeNull()
|
||||
})
|
||||
|
||||
it('normalizes exposure and disposes renderer resources', async () => {
|
||||
const viewer = createViewer()
|
||||
|
||||
await viewer.mount(makeContainer(), '/api/view?filename=scene.exr')
|
||||
viewer.normalizeExposure()
|
||||
viewer.dispose()
|
||||
|
||||
expect(viewer.exposureStops.value).toBe(-2)
|
||||
expect(mocks.viewportDisposeRenderer).toHaveBeenCalled()
|
||||
expect(mocks.textureDispose).toHaveBeenCalled()
|
||||
expect(mocks.materialDispose).toHaveBeenCalled()
|
||||
expect(mocks.geometryDispose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handles no-op viewer actions before mounting', () => {
|
||||
const viewer = createViewer()
|
||||
|
||||
viewer.fitView()
|
||||
viewer.normalizeExposure()
|
||||
viewer.dispose()
|
||||
|
||||
expect(viewer.exposureStops.value).toBe(0)
|
||||
expect(mocks.viewportDisposeRenderer).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves sample-derived state empty when texture data is missing', async () => {
|
||||
const noData: unknown = undefined
|
||||
mocks.exrLoad.mockImplementation(
|
||||
(
|
||||
_url: string,
|
||||
onLoad: (texture: MockTexture, textureData?: unknown) => void
|
||||
) =>
|
||||
onLoad({
|
||||
...makeTexture(),
|
||||
image: {
|
||||
width: 2,
|
||||
height: 1,
|
||||
data: noData as ArrayLike<number>
|
||||
}
|
||||
})
|
||||
)
|
||||
const viewer = createViewer()
|
||||
|
||||
await viewer.mount(makeContainer(), '/api/view?filename=scene.exr')
|
||||
viewer.normalizeExposure()
|
||||
|
||||
expect(viewer.dimensions.value).toBe('2 x 1')
|
||||
expect(viewer.stats.value).toBeNull()
|
||||
expect(viewer.histogram.value).toBeNull()
|
||||
expect(viewer.exposureStops.value).toBe(0)
|
||||
})
|
||||
|
||||
it('disposes textures that finish loading after viewer disposal', async () => {
|
||||
let resolveLoad: (
|
||||
texture: MockTexture,
|
||||
textureData?: unknown
|
||||
) => void = () => {}
|
||||
mocks.exrLoad.mockImplementation(
|
||||
(
|
||||
_url: string,
|
||||
onLoad: (texture: MockTexture, textureData?: unknown) => void
|
||||
) => {
|
||||
resolveLoad = onLoad
|
||||
}
|
||||
)
|
||||
const viewer = createViewer()
|
||||
const mounting = viewer.mount(
|
||||
makeContainer(),
|
||||
'/api/view?filename=scene.exr'
|
||||
)
|
||||
|
||||
viewer.dispose()
|
||||
resolveLoad(makeTexture())
|
||||
await mounting
|
||||
|
||||
expect(mocks.textureDispose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports loader errors and clears loading state', async () => {
|
||||
mocks.exrLoad.mockImplementation(
|
||||
(
|
||||
_url: string,
|
||||
_onLoad: (texture: MockTexture, textureData?: unknown) => void,
|
||||
_onProgress: unknown,
|
||||
onError: (error: unknown) => void
|
||||
) => onError(new Error('load failed'))
|
||||
)
|
||||
const viewer = createViewer()
|
||||
|
||||
await viewer.mount(makeContainer(), '/api/view?filename=broken.exr')
|
||||
|
||||
expect(viewer.error.value).toBe('load failed')
|
||||
expect(viewer.loading.value).toBe(false)
|
||||
expect(mocks.viewportDisposeRenderer).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports string loader errors', async () => {
|
||||
mocks.exrLoad.mockImplementation(
|
||||
(
|
||||
_url: string,
|
||||
_onLoad: (texture: MockTexture, textureData?: unknown) => void,
|
||||
_onProgress: unknown,
|
||||
onError: (error: unknown) => void
|
||||
) => onError('load failed')
|
||||
)
|
||||
const viewer = createViewer()
|
||||
|
||||
await viewer.mount(makeContainer(), '/api/view?filename=broken.exr')
|
||||
|
||||
expect(viewer.error.value).toBe('load failed')
|
||||
})
|
||||
|
||||
it('zooms with the wheel and pans while dragging', async () => {
|
||||
const viewer = createViewer()
|
||||
await viewer.mount(makeContainer(), '/api/view?filename=scene.exr')
|
||||
|
||||
mocks.lastCanvas!.dispatchEvent(new WheelEvent('wheel', { deltaY: -1000 }))
|
||||
mocks.lastCanvas!.dispatchEvent(
|
||||
new PointerEvent('pointerdown', { clientX: 10, clientY: 10 })
|
||||
)
|
||||
window.dispatchEvent(
|
||||
new PointerEvent('pointermove', { clientX: 20, clientY: 30 })
|
||||
)
|
||||
window.dispatchEvent(new PointerEvent('pointerup'))
|
||||
|
||||
expect(mocks.updateProjectionMatrix).toHaveBeenCalled()
|
||||
expect(mocks.render).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores hover sampling while dragging', async () => {
|
||||
const viewer = createViewer()
|
||||
await viewer.mount(makeContainer(), '/api/view?filename=scene.exr')
|
||||
|
||||
mocks.lastCanvas!.dispatchEvent(
|
||||
new PointerEvent('pointerdown', { clientX: 10, clientY: 10 })
|
||||
)
|
||||
mocks.raycasterSetFromCamera.mockClear()
|
||||
mocks.lastCanvas!.dispatchEvent(
|
||||
new PointerEvent('pointermove', { clientX: 20, clientY: 20 })
|
||||
)
|
||||
window.dispatchEvent(new PointerEvent('pointerup'))
|
||||
|
||||
expect(mocks.raycasterSetFromCamera).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,190 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, defineComponent } from 'vue'
|
||||
|
||||
const mockSettingGet = vi.hoisted(() => vi.fn())
|
||||
const mockTrackUiButtonClicked = vi.hoisted(() => vi.fn())
|
||||
const mockReleaseStore = vi.hoisted(() => ({
|
||||
shouldShowRedDot: { value: false },
|
||||
initialize: vi.fn()
|
||||
}))
|
||||
const mockHelpCenterStore = vi.hoisted(() => ({
|
||||
isVisible: { value: false },
|
||||
toggle: vi.fn(),
|
||||
hide: vi.fn()
|
||||
}))
|
||||
const mockConflictDetection = vi.hoisted(() => ({
|
||||
shouldShowConflictModalAfterUpdate: vi.fn()
|
||||
}))
|
||||
const mockShowNodeConflictDialog = vi.hoisted(() => vi.fn())
|
||||
const mockConflictAcknowledgment = vi.hoisted(() => ({
|
||||
shouldShowRedDot: { value: false },
|
||||
markConflictsAsSeen: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('pinia', () => ({
|
||||
storeToRefs: (store: Record<string, unknown>) => store
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({ get: mockSettingGet })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({
|
||||
trackUiButtonClicked: mockTrackUiButtonClicked
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/updates/common/releaseStore', () => ({
|
||||
useReleaseStore: () => mockReleaseStore
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/helpCenterStore', () => ({
|
||||
useHelpCenterStore: () => mockHelpCenterStore
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/workbench/extensions/manager/composables/useConflictDetection',
|
||||
() => ({
|
||||
useConflictDetection: () => mockConflictDetection
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock(
|
||||
'@/workbench/extensions/manager/composables/useNodeConflictDialog',
|
||||
() => ({
|
||||
useNodeConflictDialog: () => ({ show: mockShowNodeConflictDialog })
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock(
|
||||
'@/workbench/extensions/manager/composables/useConflictAcknowledgment',
|
||||
() => ({
|
||||
useConflictAcknowledgment: () => mockConflictAcknowledgment
|
||||
})
|
||||
)
|
||||
|
||||
import { useHelpCenter } from './useHelpCenter'
|
||||
|
||||
function mountHelpCenter() {
|
||||
let result: ReturnType<typeof useHelpCenter> | undefined
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup() {
|
||||
result = useHelpCenter()
|
||||
return () => null
|
||||
}
|
||||
})
|
||||
)
|
||||
app.mount(document.createElement('div'))
|
||||
if (!result) throw new Error('Expected help center composable to initialize')
|
||||
return { app, result }
|
||||
}
|
||||
|
||||
describe('useHelpCenter', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockSettingGet.mockReturnValue('left')
|
||||
mockReleaseStore.shouldShowRedDot.value = false
|
||||
mockHelpCenterStore.isVisible.value = false
|
||||
mockHelpCenterStore.toggle.mockImplementation(() => {
|
||||
mockHelpCenterStore.isVisible.value = !mockHelpCenterStore.isVisible.value
|
||||
})
|
||||
mockHelpCenterStore.hide.mockImplementation(() => {
|
||||
mockHelpCenterStore.isVisible.value = false
|
||||
})
|
||||
mockConflictAcknowledgment.shouldShowRedDot.value = false
|
||||
mockConflictDetection.shouldShowConflictModalAfterUpdate.mockResolvedValue(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('initializes releases on mount and exposes store-backed computed state', async () => {
|
||||
mockReleaseStore.shouldShowRedDot.value = true
|
||||
const { app, result } = mountHelpCenter()
|
||||
|
||||
expect(mockReleaseStore.initialize).toHaveBeenCalledOnce()
|
||||
expect(result.isHelpCenterVisible.value).toBe(false)
|
||||
expect(result.shouldShowRedDot.value).toBe(true)
|
||||
expect(result.sidebarLocation.value).toBe('left')
|
||||
|
||||
app.unmount()
|
||||
})
|
||||
|
||||
it('uses the conflict red dot when the release red dot is hidden', () => {
|
||||
mockConflictAcknowledgment.shouldShowRedDot.value = true
|
||||
const { app, result } = mountHelpCenter()
|
||||
|
||||
expect(result.shouldShowRedDot.value).toBe(true)
|
||||
|
||||
app.unmount()
|
||||
})
|
||||
|
||||
it('tracks and toggles help center visibility', () => {
|
||||
const { app, result } = mountHelpCenter()
|
||||
|
||||
result.toggleHelpCenter()
|
||||
|
||||
expect(mockTrackUiButtonClicked).toHaveBeenCalledWith({
|
||||
button_id: 'sidebar_help_center_toggled',
|
||||
element_group: 'sidebar'
|
||||
})
|
||||
expect(mockHelpCenterStore.toggle).toHaveBeenCalledOnce()
|
||||
expect(mockHelpCenterStore.isVisible.value).toBe(true)
|
||||
|
||||
result.closeHelpCenter()
|
||||
|
||||
expect(mockHelpCenterStore.hide).toHaveBeenCalledOnce()
|
||||
expect(mockHelpCenterStore.isVisible.value).toBe(false)
|
||||
app.unmount()
|
||||
})
|
||||
|
||||
it('opens the conflict modal after the whats-new dialog when needed', async () => {
|
||||
mockConflictDetection.shouldShowConflictModalAfterUpdate.mockResolvedValue(
|
||||
true
|
||||
)
|
||||
const { app, result } = mountHelpCenter()
|
||||
|
||||
await result.handleWhatsNewDismissed()
|
||||
|
||||
expect(mockShowNodeConflictDialog).toHaveBeenCalledWith({
|
||||
showAfterWhatsNew: true,
|
||||
dialogComponentProps: {
|
||||
onClose: expect.any(Function)
|
||||
}
|
||||
})
|
||||
const options = mockShowNodeConflictDialog.mock.calls[0][0]
|
||||
options.dialogComponentProps.onClose()
|
||||
expect(
|
||||
mockConflictAcknowledgment.markConflictsAsSeen
|
||||
).toHaveBeenCalledOnce()
|
||||
app.unmount()
|
||||
})
|
||||
|
||||
it('does not open the conflict modal when not needed', async () => {
|
||||
const { app, result } = mountHelpCenter()
|
||||
|
||||
await result.handleWhatsNewDismissed()
|
||||
|
||||
expect(mockShowNodeConflictDialog).not.toHaveBeenCalled()
|
||||
app.unmount()
|
||||
})
|
||||
|
||||
it('logs conflict-check failures without throwing', async () => {
|
||||
const error = new Error('failed')
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
mockConflictDetection.shouldShowConflictModalAfterUpdate.mockRejectedValue(
|
||||
error
|
||||
)
|
||||
const { app, result } = mountHelpCenter()
|
||||
|
||||
await expect(result.handleWhatsNewDismissed()).resolves.toBeUndefined()
|
||||
|
||||
expect(consoleError).toHaveBeenCalledWith(
|
||||
'[HelpCenter] Error checking conflict modal:',
|
||||
error
|
||||
)
|
||||
app.unmount()
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
})
|
||||
@@ -137,7 +137,7 @@ function mountContainerLayout(
|
||||
|
||||
function makePointerEvent(
|
||||
type: 'pointerdown' | 'pointermove' | 'pointerup',
|
||||
target: EventTarget,
|
||||
target: HTMLElement,
|
||||
clientX: number,
|
||||
clientY: number
|
||||
) {
|
||||
@@ -188,8 +188,7 @@ async function mountHarness(nodeId: NodeId = toNodeId(2)) {
|
||||
const el = document.createElement('div')
|
||||
document.body.appendChild(el)
|
||||
const app = createApp(ImageCropHarness, { nodeId: Number(nodeId) })
|
||||
const mounted: unknown = app.mount(el)
|
||||
const vm = mounted as CropVm
|
||||
const vm = app.mount(el) as unknown as CropVm
|
||||
await nextTick()
|
||||
await Promise.resolve()
|
||||
harnessCleanups.push(() => {
|
||||
@@ -303,32 +302,6 @@ describe('useImageCrop', () => {
|
||||
expect(vm.imageUrl).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when a subgraph output link cannot be resolved', async () => {
|
||||
const subgraphInput = createMockSubgraphNode([], {
|
||||
id: 40,
|
||||
resolveSubgraphOutputLink: vi.fn(() => undefined)
|
||||
})
|
||||
const sgCrop = createMockLGraphNode({
|
||||
id: 2,
|
||||
getInputNode: vi.fn(() => subgraphInput),
|
||||
getInputLink: vi.fn(() => ({ origin_slot: 0 })),
|
||||
isSubgraphNode: () => false
|
||||
})
|
||||
mockResolveNode.mockReturnValue(sgCrop)
|
||||
const vm = await mountHarness()
|
||||
expect(vm.imageUrl).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when the source node has no image URLs', async () => {
|
||||
mockGetNodeImageUrls.mockImplementation((n) =>
|
||||
n === sourceNode ? [] : null
|
||||
)
|
||||
|
||||
const vm = await mountHarness()
|
||||
|
||||
expect(vm.imageUrl).toBeNull()
|
||||
})
|
||||
|
||||
it('resolves image through a subgraph input node', async () => {
|
||||
const innerSource = createMockLGraphNode({
|
||||
id: 50,
|
||||
@@ -367,18 +340,6 @@ describe('useImageCrop', () => {
|
||||
expect(vm.imageUrl).toBe('https://example.com/b.png')
|
||||
})
|
||||
|
||||
it('keeps loading unchanged when output updates keep the same URL', async () => {
|
||||
const vm = await mountHarness()
|
||||
;(vm.handleImageLoad as () => void)()
|
||||
expect(vm.isLoading).toBe(false)
|
||||
|
||||
outputStore.nodeOutputs['touch'] = { updated: true }
|
||||
|
||||
await flushTicks()
|
||||
expect(vm.imageUrl).toBe('https://example.com/a.png')
|
||||
expect(vm.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
it('updates imageUrl when nodePreviewImages change', async () => {
|
||||
let url = 'https://example.com/a.png'
|
||||
mockGetNodeImageUrls.mockImplementation((n) =>
|
||||
@@ -429,30 +390,6 @@ describe('useImageCrop', () => {
|
||||
expect(parseFloat(style.height)).toBeCloseTo(80, 1)
|
||||
})
|
||||
|
||||
it('ignores resize observer callbacks before an image is rendered', async () => {
|
||||
mockGetNodeImageUrls.mockReturnValue(null)
|
||||
const vm = await mountHarness()
|
||||
|
||||
flushResizeObservers()
|
||||
|
||||
expect(vm.imageUrl).toBeNull()
|
||||
expect(vm.cropBoxStyle).toEqual({
|
||||
left: '38px',
|
||||
top: '38px',
|
||||
width: '160px',
|
||||
height: '120px'
|
||||
})
|
||||
})
|
||||
|
||||
it('uses default crop dimensions when model dimensions are zero', async () => {
|
||||
const vm = await mountHarness()
|
||||
setupImageLayout(vm, 400, 400)
|
||||
vm.modelValue = { x: 0, y: 0, width: 0, height: 0 }
|
||||
|
||||
expect(vm.cropWidth).toBe(512)
|
||||
expect(vm.cropHeight).toBe(512)
|
||||
})
|
||||
|
||||
it('exposes eight resize handles when unlocked and four when locked', async () => {
|
||||
const vm = await mountHarness()
|
||||
setupImageLayout(vm, 400, 400)
|
||||
@@ -486,48 +423,6 @@ describe('useImageCrop', () => {
|
||||
expect(vm.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
it('uses fallback scale when dragging before image dimensions are known', async () => {
|
||||
const vm = await mountHarness()
|
||||
vm.modelValue = { x: 10, y: 10, width: 120, height: 90 }
|
||||
const captureEl = document.createElement('div')
|
||||
captureEl.setPointerCapture = vi.fn()
|
||||
captureEl.releasePointerCapture = vi.fn()
|
||||
|
||||
const dragStart = vm.handleDragStart as (e: PointerEvent) => void
|
||||
const dragMove = vm.handleDragMove as (e: PointerEvent) => void
|
||||
const dragEnd = vm.handleDragEnd as (e: PointerEvent) => void
|
||||
|
||||
dragStart(makePointerEvent('pointerdown', captureEl, 10, 10))
|
||||
dragMove(makePointerEvent('pointermove', captureEl, 30, 30))
|
||||
dragEnd(makePointerEvent('pointerup', captureEl, 30, 30))
|
||||
|
||||
expect(vm.modelValue.x).toBe(0)
|
||||
expect(vm.modelValue.y).toBe(0)
|
||||
})
|
||||
|
||||
it('uses fallback scale when rendered container width is missing', async () => {
|
||||
const vm = await mountHarness()
|
||||
setupImageLayout(vm, 400, 400)
|
||||
mountContainerLayout(vm.$el, 0, 300, 0)
|
||||
vm.modelValue = { x: 10, y: 10, width: 120, height: 90 }
|
||||
const captureEl = document.createElement('div')
|
||||
captureEl.setPointerCapture = vi.fn()
|
||||
captureEl.releasePointerCapture = vi.fn()
|
||||
|
||||
const resizeStart = vm.handleResizeStart as (
|
||||
e: PointerEvent,
|
||||
dir: string
|
||||
) => void
|
||||
const resizeMove = vm.handleResizeMove as (e: PointerEvent) => void
|
||||
const resizeEnd = vm.handleResizeEnd as (e: PointerEvent) => void
|
||||
|
||||
resizeStart(makePointerEvent('pointerdown', captureEl, 130, 80), 'right')
|
||||
resizeMove(makePointerEvent('pointermove', captureEl, 150, 80))
|
||||
resizeEnd(makePointerEvent('pointerup', captureEl, 150, 80))
|
||||
|
||||
expect(vm.modelValue.width).toBe(140)
|
||||
})
|
||||
|
||||
it('does not start dragging when there is no image', async () => {
|
||||
mockGetNodeImageUrls.mockReturnValue(null)
|
||||
const vm = await mountHarness()
|
||||
@@ -541,41 +436,6 @@ describe('useImageCrop', () => {
|
||||
expect(vm.cropX as number).toBe(xBefore)
|
||||
})
|
||||
|
||||
it('ignores drag move and end before dragging starts', async () => {
|
||||
const vm = await mountHarness()
|
||||
setupImageLayout(vm, 400, 300)
|
||||
vm.modelValue = { x: 10, y: 10, width: 120, height: 90 }
|
||||
const releaseEl = document.createElement('div')
|
||||
releaseEl.releasePointerCapture = vi.fn()
|
||||
|
||||
;(vm.handleDragMove as (e: PointerEvent) => void)(
|
||||
makePointerEvent('pointermove', releaseEl, 260, 180)
|
||||
)
|
||||
;(vm.handleDragEnd as (e: PointerEvent) => void)(
|
||||
makePointerEvent('pointerup', releaseEl, 260, 180)
|
||||
)
|
||||
|
||||
expect(vm.modelValue).toEqual({ x: 10, y: 10, width: 120, height: 90 })
|
||||
expect(releaseEl.releasePointerCapture).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('drags without pointer capture when the event target is not an element', async () => {
|
||||
const vm = await mountHarness()
|
||||
setupImageLayout(vm, 400, 300)
|
||||
vm.modelValue = { x: 10, y: 10, width: 120, height: 90 }
|
||||
|
||||
const dragStart = vm.handleDragStart as (e: PointerEvent) => void
|
||||
const dragMove = vm.handleDragMove as (e: PointerEvent) => void
|
||||
const dragEnd = vm.handleDragEnd as (e: PointerEvent) => void
|
||||
|
||||
dragStart(makePointerEvent('pointerdown', document, 200, 150))
|
||||
dragMove(makePointerEvent('pointermove', document, 260, 180))
|
||||
dragEnd(makePointerEvent('pointerup', document, 260, 180))
|
||||
|
||||
expect(vm.modelValue.x).toBeGreaterThan(10)
|
||||
expect(vm.modelValue.y).toBeGreaterThan(10)
|
||||
})
|
||||
|
||||
it('drags the crop box in image space and ends on pointerup', async () => {
|
||||
const vm = await mountHarness()
|
||||
setupImageLayout(vm, 400, 300)
|
||||
@@ -646,62 +506,6 @@ describe('useImageCrop', () => {
|
||||
expect(vm.modelValue.height).toBeLessThan(200)
|
||||
})
|
||||
|
||||
it('resizes from the left edge and clamps to the image origin', async () => {
|
||||
const vm = await mountHarness()
|
||||
setupImageLayout(vm, 500, 500)
|
||||
vm.modelValue = { x: 50, y: 50, width: 120, height: 100 }
|
||||
|
||||
const captureEl = document.createElement('div')
|
||||
captureEl.setPointerCapture = vi.fn()
|
||||
captureEl.releasePointerCapture = vi.fn()
|
||||
|
||||
const resizeStart = vm.handleResizeStart as (
|
||||
e: PointerEvent,
|
||||
dir: string
|
||||
) => void
|
||||
const resizeMove = vm.handleResizeMove as (e: PointerEvent) => void
|
||||
const resizeEnd = vm.handleResizeEnd as (e: PointerEvent) => void
|
||||
|
||||
resizeStart(makePointerEvent('pointerdown', captureEl, 100, 120), 'left')
|
||||
resizeMove(makePointerEvent('pointermove', captureEl, -100, 120))
|
||||
resizeEnd(makePointerEvent('pointerup', captureEl, -100, 120))
|
||||
|
||||
expect(vm.modelValue.x).toBe(0)
|
||||
expect(vm.modelValue.width).toBeGreaterThan(120)
|
||||
})
|
||||
|
||||
it('ignores resize move and end before resizing starts', async () => {
|
||||
const vm = await mountHarness()
|
||||
setupImageLayout(vm, 400, 400)
|
||||
vm.modelValue = { x: 40, y: 40, width: 120, height: 120 }
|
||||
const releaseEl = document.createElement('div')
|
||||
releaseEl.releasePointerCapture = vi.fn()
|
||||
|
||||
;(vm.handleResizeMove as (e: PointerEvent) => void)(
|
||||
makePointerEvent('pointermove', releaseEl, 360, 360)
|
||||
)
|
||||
;(vm.handleResizeEnd as (e: PointerEvent) => void)(
|
||||
makePointerEvent('pointerup', releaseEl, 360, 360)
|
||||
)
|
||||
|
||||
expect(vm.modelValue).toEqual({ x: 40, y: 40, width: 120, height: 120 })
|
||||
expect(releaseEl.releasePointerCapture).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not start resizing when there is no image', async () => {
|
||||
mockGetNodeImageUrls.mockReturnValue(null)
|
||||
const vm = await mountHarness()
|
||||
const captureEl = document.createElement('div')
|
||||
captureEl.setPointerCapture = vi.fn()
|
||||
|
||||
;(vm.handleResizeStart as (e: PointerEvent, dir: string) => void)(
|
||||
makePointerEvent('pointerdown', captureEl, 20, 20),
|
||||
'right'
|
||||
)
|
||||
|
||||
expect(captureEl.setPointerCapture).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('applies a preset aspect ratio and clamps height to the image', async () => {
|
||||
const vm = await mountHarness()
|
||||
setupImageLayout(vm, 800, 500)
|
||||
@@ -720,25 +524,6 @@ describe('useImageCrop', () => {
|
||||
expect(vm.isLockEnabled).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores unknown aspect-ratio presets and unlocks explicit lock changes', async () => {
|
||||
const vm = await mountHarness()
|
||||
setupImageLayout(vm, 400, 400)
|
||||
vm.modelValue = { x: 0, y: 0, width: 160, height: 120 }
|
||||
|
||||
vm.selectedRatio = 'missing'
|
||||
expect(vm.selectedRatio).toBe('custom')
|
||||
expect(vm.isLockEnabled).toBe(false)
|
||||
|
||||
vm.isLockEnabled = true
|
||||
expect(vm.selectedRatio).toBe('4:3')
|
||||
|
||||
vm.isLockEnabled = true
|
||||
expect(vm.selectedRatio).toBe('4:3')
|
||||
|
||||
vm.isLockEnabled = false
|
||||
expect(vm.selectedRatio).toBe('custom')
|
||||
})
|
||||
|
||||
it('shows custom in the ratio label when lock does not match a preset', async () => {
|
||||
const vm = await mountHarness()
|
||||
setupImageLayout(vm, 400, 400)
|
||||
@@ -798,58 +583,6 @@ describe('useImageCrop', () => {
|
||||
expect(vm.modelValue.y + vm.modelValue.height).toBeLessThanOrEqual(400)
|
||||
})
|
||||
|
||||
it('clamps constrained north-west resize to the image top-left bounds', async () => {
|
||||
const vm = await mountHarness()
|
||||
setupImageLayout(vm, 400, 400)
|
||||
vm.modelValue = { x: 20, y: 20, width: 80, height: 80 }
|
||||
vm.isLockEnabled = true
|
||||
|
||||
const captureEl = document.createElement('div')
|
||||
captureEl.setPointerCapture = vi.fn()
|
||||
captureEl.releasePointerCapture = vi.fn()
|
||||
|
||||
const resizeStart = vm.handleResizeStart as (
|
||||
e: PointerEvent,
|
||||
dir: string
|
||||
) => void
|
||||
const resizeMove = vm.handleResizeMove as (e: PointerEvent) => void
|
||||
const resizeEnd = vm.handleResizeEnd as (e: PointerEvent) => void
|
||||
|
||||
resizeStart(makePointerEvent('pointerdown', captureEl, 40, 40), 'nw')
|
||||
resizeMove(makePointerEvent('pointermove', captureEl, -200, -200))
|
||||
resizeEnd(makePointerEvent('pointerup', captureEl, -200, -200))
|
||||
|
||||
expect(vm.modelValue.x).toBeGreaterThanOrEqual(0)
|
||||
expect(vm.modelValue.y).toBeGreaterThanOrEqual(0)
|
||||
expect(vm.modelValue.width).toBeGreaterThanOrEqual(16)
|
||||
expect(vm.modelValue.height).toBeGreaterThanOrEqual(16)
|
||||
})
|
||||
|
||||
it('clamps constrained corner resize to minimum dimensions', async () => {
|
||||
const vm = await mountHarness()
|
||||
setupImageLayout(vm, 400, 400)
|
||||
vm.modelValue = { x: 40, y: 40, width: 160, height: 80 }
|
||||
vm.isLockEnabled = true
|
||||
|
||||
const captureEl = document.createElement('div')
|
||||
captureEl.setPointerCapture = vi.fn()
|
||||
captureEl.releasePointerCapture = vi.fn()
|
||||
|
||||
const resizeStart = vm.handleResizeStart as (
|
||||
e: PointerEvent,
|
||||
dir: string
|
||||
) => void
|
||||
const resizeMove = vm.handleResizeMove as (e: PointerEvent) => void
|
||||
const resizeEnd = vm.handleResizeEnd as (e: PointerEvent) => void
|
||||
|
||||
resizeStart(makePointerEvent('pointerdown', captureEl, 200, 120), 'se')
|
||||
resizeMove(makePointerEvent('pointermove', captureEl, -800, -800))
|
||||
resizeEnd(makePointerEvent('pointerup', captureEl, -800, -800))
|
||||
|
||||
expect(vm.modelValue.width).toBe(32)
|
||||
expect(vm.modelValue.height).toBe(16)
|
||||
})
|
||||
|
||||
it('ends resize and clears direction on pointerup', async () => {
|
||||
const vm = await mountHarness()
|
||||
setupImageLayout(vm, 400, 400)
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { createApp, h, ref } from 'vue'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
type ObserverInit = ConstructorParameters<typeof IntersectionObserver>[1]
|
||||
type ObserverCallback = ConstructorParameters<typeof IntersectionObserver>[0]
|
||||
|
||||
const observers: MockIntersectionObserver[] = []
|
||||
const hadOriginalIntersectionObserver = 'IntersectionObserver' in window
|
||||
const originalIntersectionObserver = window.IntersectionObserver
|
||||
|
||||
class MockIntersectionObserver {
|
||||
readonly callback: ObserverCallback
|
||||
readonly options?: ObserverInit
|
||||
readonly observe = vi.fn()
|
||||
readonly unobserve = vi.fn()
|
||||
readonly disconnect = vi.fn()
|
||||
|
||||
constructor(callback: ObserverCallback, options?: ObserverInit) {
|
||||
this.callback = callback
|
||||
this.options = options
|
||||
observers.push(this)
|
||||
}
|
||||
}
|
||||
|
||||
function mountObserver(
|
||||
target: Ref<Element | null>,
|
||||
callback: IntersectionObserverCallback,
|
||||
options: Parameters<typeof useIntersectionObserver>[2] = {}
|
||||
) {
|
||||
let result: ReturnType<typeof useIntersectionObserver> | undefined
|
||||
const app = createApp({
|
||||
setup() {
|
||||
result = useIntersectionObserver(target, callback, options)
|
||||
return () => h('div')
|
||||
}
|
||||
})
|
||||
app.mount(document.createElement('div'))
|
||||
if (!result) throw new Error('useIntersectionObserver did not initialize')
|
||||
return {
|
||||
result,
|
||||
unmount: () => app.unmount()
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
observers.length = 0
|
||||
Object.defineProperty(window, 'IntersectionObserver', {
|
||||
configurable: true,
|
||||
value: MockIntersectionObserver
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (hadOriginalIntersectionObserver) {
|
||||
Object.defineProperty(window, 'IntersectionObserver', {
|
||||
configurable: true,
|
||||
value: originalIntersectionObserver
|
||||
})
|
||||
} else {
|
||||
Reflect.deleteProperty(window, 'IntersectionObserver')
|
||||
}
|
||||
})
|
||||
|
||||
describe('useIntersectionObserver', () => {
|
||||
it('observes the target immediately and updates intersection state', async () => {
|
||||
const target = ref<Element | null>(document.createElement('div'))
|
||||
const callback = vi.fn()
|
||||
|
||||
const { result, unmount } = mountObserver(target, callback, {
|
||||
threshold: 0.5
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
expect(result.isSupported).toBe(true)
|
||||
expect(observers).toHaveLength(1)
|
||||
expect(observers[0].options).toMatchObject({ threshold: 0.5 })
|
||||
expect(observers[0].observe).toHaveBeenCalledWith(target.value)
|
||||
|
||||
observers[0].callback(
|
||||
[
|
||||
{ isIntersecting: false },
|
||||
{ isIntersecting: true }
|
||||
] as IntersectionObserverEntry[],
|
||||
fromPartial<IntersectionObserver>(observers[0])
|
||||
)
|
||||
|
||||
expect(result.isIntersecting.value).toBe(true)
|
||||
expect(callback).toHaveBeenCalled()
|
||||
|
||||
unmount()
|
||||
expect(observers[0].disconnect).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('supports manual observe, unobserve, and cleanup', () => {
|
||||
const target = ref<Element | null>(document.createElement('div'))
|
||||
const { result, unmount } = mountObserver(target, vi.fn(), {
|
||||
immediate: false
|
||||
})
|
||||
|
||||
expect(observers).toHaveLength(0)
|
||||
|
||||
result.observe()
|
||||
expect(observers).toHaveLength(1)
|
||||
expect(observers[0].observe).toHaveBeenCalledWith(target.value)
|
||||
|
||||
result.unobserve()
|
||||
expect(observers[0].unobserve).toHaveBeenCalledWith(target.value)
|
||||
|
||||
result.cleanup()
|
||||
expect(observers[0].disconnect).toHaveBeenCalled()
|
||||
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('does nothing when unsupported or missing a target', () => {
|
||||
Reflect.deleteProperty(window, 'IntersectionObserver')
|
||||
const unsupported = mountObserver(
|
||||
ref(document.createElement('div')),
|
||||
vi.fn(),
|
||||
{
|
||||
immediate: false
|
||||
}
|
||||
)
|
||||
|
||||
unsupported.result.observe()
|
||||
|
||||
expect(unsupported.result.isSupported).toBe(false)
|
||||
expect(observers).toHaveLength(0)
|
||||
unsupported.unmount()
|
||||
|
||||
Object.defineProperty(window, 'IntersectionObserver', {
|
||||
configurable: true,
|
||||
value: MockIntersectionObserver
|
||||
})
|
||||
const missingTarget = mountObserver(ref<Element | null>(null), vi.fn(), {
|
||||
immediate: false
|
||||
})
|
||||
|
||||
missingTarget.result.observe()
|
||||
|
||||
expect(observers).toHaveLength(0)
|
||||
missingTarget.unmount()
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,8 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import { useLoad3dViewer } from '@/composables/useLoad3dViewer'
|
||||
import Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import type { CameraState } from '@/extensions/core/load3d/interfaces'
|
||||
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
|
||||
import { createLoad3d } from '@/extensions/core/load3d/createLoad3d'
|
||||
import type { LGraph } from '@/lib/litegraph/src/LGraph'
|
||||
@@ -99,9 +97,9 @@ describe('useLoad3dViewer', () => {
|
||||
},
|
||||
'Resource Folder': ''
|
||||
},
|
||||
graph: fromPartial<LGraph>({
|
||||
graph: {
|
||||
setDirtyCanvas: vi.fn()
|
||||
}),
|
||||
} as Partial<LGraph> as LGraph,
|
||||
widgets: []
|
||||
})
|
||||
|
||||
@@ -130,9 +128,6 @@ describe('useLoad3dViewer', () => {
|
||||
setCameraState: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
hasAnimations: vi.fn().mockReturnValue(false),
|
||||
toggleAnimation: vi.fn(),
|
||||
setAnimationSpeed: vi.fn(),
|
||||
updateSelectedAnimation: vi.fn(),
|
||||
isSplatModel: vi.fn().mockReturnValue(false),
|
||||
isPlyModel: vi.fn().mockReturnValue(false),
|
||||
getSourceFormat: vi.fn().mockReturnValue(null),
|
||||
@@ -152,11 +147,6 @@ describe('useLoad3dViewer', () => {
|
||||
position: { x: 0, y: 0, z: 0 },
|
||||
rotation: { x: 0, y: 0, z: 0 },
|
||||
scale: { x: 1, y: 1, z: 1 }
|
||||
}),
|
||||
setAnimationTime: vi.fn(),
|
||||
getAnimationDuration: vi.fn().mockReturnValue(12),
|
||||
animationManager: fromPartial<Load3d['animationManager']>({
|
||||
animationClips: []
|
||||
})
|
||||
}
|
||||
|
||||
@@ -179,18 +169,6 @@ describe('useLoad3dViewer', () => {
|
||||
currentUpDirection: 'original',
|
||||
materialMode: 'original'
|
||||
} as Load3d['modelManager'],
|
||||
isSplatModel: vi.fn().mockReturnValue(false),
|
||||
isPlyModel: vi.fn().mockReturnValue(false),
|
||||
getSourceFormat: vi.fn().mockReturnValue(null),
|
||||
getCurrentModelCapabilities: vi.fn().mockReturnValue({
|
||||
fitToViewer: true,
|
||||
requiresMaterialRebuild: false,
|
||||
gizmoTransform: true,
|
||||
lighting: true,
|
||||
exportable: true,
|
||||
materialModes: ['original', 'normal', 'wireframe'],
|
||||
fitTargetSize: 5
|
||||
}),
|
||||
setBackgroundImage: vi.fn().mockResolvedValue(undefined),
|
||||
setBackgroundRenderMode: vi.fn(),
|
||||
forceRender: vi.fn()
|
||||
@@ -201,16 +179,20 @@ describe('useLoad3dViewer', () => {
|
||||
})
|
||||
vi.mocked(createLoad3d).mockImplementation(() => mockLoad3d as Load3d)
|
||||
|
||||
mockLoad3dService = fromPartial<ReturnType<typeof useLoad3dService>>({
|
||||
mockLoad3dService = {
|
||||
copyLoad3dState: vi.fn().mockResolvedValue(undefined),
|
||||
handleViewportRefresh: vi.fn(),
|
||||
getLoad3d: vi.fn().mockReturnValue(mockSourceLoad3d)
|
||||
})
|
||||
} as Partial<ReturnType<typeof useLoad3dService>> as ReturnType<
|
||||
typeof useLoad3dService
|
||||
>
|
||||
vi.mocked(useLoad3dService).mockReturnValue(mockLoad3dService)
|
||||
|
||||
mockToastStore = fromPartial<ReturnType<typeof useToastStore>>({
|
||||
mockToastStore = {
|
||||
addAlert: vi.fn()
|
||||
})
|
||||
} as Partial<ReturnType<typeof useToastStore>> as ReturnType<
|
||||
typeof useToastStore
|
||||
>
|
||||
vi.mocked(useToastStore).mockReturnValue(mockToastStore)
|
||||
})
|
||||
|
||||
@@ -266,91 +248,6 @@ describe('useLoad3dViewer', () => {
|
||||
expect(viewer.hasBackgroundImage.value).toBe(true)
|
||||
})
|
||||
|
||||
it('passes target dimensions when width and height widgets are present', async () => {
|
||||
mockNode.widgets = [
|
||||
{ name: 'width', value: 640 },
|
||||
{ name: 'height', value: 360 }
|
||||
] as LGraphNode['widgets']
|
||||
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
|
||||
|
||||
expect(createLoad3d).toHaveBeenCalledWith(
|
||||
containerRef,
|
||||
expect.objectContaining({
|
||||
width: 640,
|
||||
height: 360,
|
||||
isViewerMode: true,
|
||||
getDimensions: expect.any(Function)
|
||||
})
|
||||
)
|
||||
const config = vi.mocked(createLoad3d).mock.calls[0][1]!
|
||||
mockNode.widgets![0].value = 800
|
||||
mockNode.widgets![1].value = 450
|
||||
expect(config.getDimensions?.()).toEqual({ width: 800, height: 450 })
|
||||
})
|
||||
|
||||
it('falls back to source values when saved configs are empty', async () => {
|
||||
mockNode.properties!['Scene Config'] = {
|
||||
backgroundColor: '',
|
||||
showGrid: undefined,
|
||||
backgroundImage: '',
|
||||
backgroundRenderMode: ''
|
||||
}
|
||||
mockNode.properties!['Camera Config'] = {
|
||||
cameraType: undefined,
|
||||
fov: undefined
|
||||
}
|
||||
mockNode.properties!['Model Config'] = {
|
||||
upDirection: undefined,
|
||||
materialMode: undefined
|
||||
}
|
||||
mockSourceLoad3d.sceneManager!.backgroundRenderMode = 'panorama'
|
||||
mockSourceLoad3d.sceneManager!.gridHelper.visible = false
|
||||
mockSourceLoad3d.sceneManager!.currentBackgroundColor = '#111111'
|
||||
vi.mocked(mockSourceLoad3d.getCurrentCameraType!).mockReturnValue(
|
||||
'orthographic'
|
||||
)
|
||||
mockSourceLoad3d.cameraManager!.perspectiveCamera.fov = 35
|
||||
mockSourceLoad3d.modelManager!.currentUpDirection = '+y'
|
||||
mockSourceLoad3d.modelManager!.materialMode = 'normal'
|
||||
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
await viewer.initializeViewer(
|
||||
document.createElement('div'),
|
||||
mockSourceLoad3d as Load3d
|
||||
)
|
||||
|
||||
expect(viewer.backgroundColor.value).toBe('#111111')
|
||||
expect(viewer.showGrid.value).toBe(false)
|
||||
expect(viewer.backgroundRenderMode.value).toBe('panorama')
|
||||
expect(viewer.cameraType.value).toBe('orthographic')
|
||||
expect(viewer.fov.value).toBe(35)
|
||||
expect(viewer.upDirection.value).toBe('+y')
|
||||
expect(viewer.materialMode.value).toBe('normal')
|
||||
})
|
||||
|
||||
it('initializes animation state from existing clips', async () => {
|
||||
vi.mocked(mockLoad3d.hasAnimations!).mockReturnValue(true)
|
||||
mockLoad3d.animationManager = fromPartial<Load3d['animationManager']>({
|
||||
animationClips: [{ name: 'Walk' }, { name: '' }]
|
||||
})
|
||||
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
await viewer.initializeViewer(
|
||||
document.createElement('div'),
|
||||
mockSourceLoad3d as Load3d
|
||||
)
|
||||
|
||||
expect(viewer.animations.value).toEqual([
|
||||
{ name: 'Walk', index: 0 },
|
||||
{ name: 'Animation 2', index: 1 }
|
||||
])
|
||||
expect(viewer.animationDuration.value).toBe(12)
|
||||
})
|
||||
|
||||
it('should handle initialization errors', async () => {
|
||||
vi.mocked(createLoad3d).mockImplementationOnce(() => {
|
||||
throw new Error('Load3d creation failed')
|
||||
@@ -368,41 +265,6 @@ describe('useLoad3dViewer', () => {
|
||||
})
|
||||
|
||||
describe('error handling', () => {
|
||||
it('ignores watcher changes before Load3d is initialized', async () => {
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
|
||||
viewer.backgroundColor.value = '#ff0000'
|
||||
viewer.showGrid.value = false
|
||||
viewer.cameraType.value = 'orthographic'
|
||||
viewer.fov.value = 60
|
||||
viewer.lightIntensity.value = 2
|
||||
viewer.backgroundImage.value = 'bg.jpg'
|
||||
viewer.backgroundRenderMode.value = 'panorama'
|
||||
viewer.upDirection.value = '+y'
|
||||
viewer.materialMode.value = 'normal'
|
||||
viewer.playing.value = true
|
||||
viewer.selectedSpeed.value = 2
|
||||
viewer.selectedAnimation.value = 1
|
||||
viewer.gizmoEnabled.value = true
|
||||
viewer.gizmoMode.value = 'rotate'
|
||||
await nextTick()
|
||||
|
||||
expect(mockLoad3d.setBackgroundColor).not.toHaveBeenCalled()
|
||||
expect(mockLoad3d.toggleGrid).not.toHaveBeenCalled()
|
||||
expect(mockLoad3d.toggleCamera).not.toHaveBeenCalled()
|
||||
expect(mockLoad3d.setFOV).not.toHaveBeenCalled()
|
||||
expect(mockLoad3d.setLightIntensity).not.toHaveBeenCalled()
|
||||
expect(mockLoad3d.setBackgroundImage).not.toHaveBeenCalled()
|
||||
expect(mockLoad3d.setBackgroundRenderMode).not.toHaveBeenCalled()
|
||||
expect(mockLoad3d.setUpDirection).not.toHaveBeenCalled()
|
||||
expect(mockLoad3d.setMaterialMode).not.toHaveBeenCalled()
|
||||
expect(mockLoad3d.toggleAnimation).not.toHaveBeenCalled()
|
||||
expect(mockLoad3d.setAnimationSpeed).not.toHaveBeenCalled()
|
||||
expect(mockLoad3d.updateSelectedAnimation).not.toHaveBeenCalled()
|
||||
expect(mockLoad3d.setGizmoEnabled).not.toHaveBeenCalled()
|
||||
expect(mockLoad3d.setGizmoMode).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle watcher errors gracefully', async () => {
|
||||
vi.mocked(mockLoad3d.setBackgroundColor!).mockImplementationOnce(
|
||||
function () {
|
||||
@@ -422,82 +284,6 @@ describe('useLoad3dViewer', () => {
|
||||
'toastMessages.failedToUpdateBackgroundColor'
|
||||
)
|
||||
})
|
||||
|
||||
it('surfaces watcher errors for each viewer control', async () => {
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined)
|
||||
vi.mocked(mockLoad3d.toggleGrid!).mockImplementation(() => {
|
||||
throw new Error('grid failed')
|
||||
})
|
||||
vi.mocked(mockLoad3d.toggleCamera!).mockImplementation(() => {
|
||||
throw new Error('camera failed')
|
||||
})
|
||||
vi.mocked(mockLoad3d.setFOV!).mockImplementation(() => {
|
||||
throw new Error('fov failed')
|
||||
})
|
||||
vi.mocked(mockLoad3d.setLightIntensity!).mockImplementation(() => {
|
||||
throw new Error('light failed')
|
||||
})
|
||||
vi.mocked(mockLoad3d.setBackgroundImage!).mockRejectedValue(
|
||||
new Error('background failed')
|
||||
)
|
||||
vi.mocked(mockLoad3d.setBackgroundRenderMode!).mockImplementation(() => {
|
||||
throw new Error('render failed')
|
||||
})
|
||||
vi.mocked(mockLoad3d.setUpDirection!).mockImplementation(() => {
|
||||
throw new Error('up failed')
|
||||
})
|
||||
vi.mocked(mockLoad3d.setMaterialMode!).mockImplementation(() => {
|
||||
throw new Error('material failed')
|
||||
})
|
||||
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
await viewer.initializeViewer(
|
||||
document.createElement('div'),
|
||||
mockSourceLoad3d as Load3d
|
||||
)
|
||||
|
||||
viewer.showGrid.value = false
|
||||
await nextTick()
|
||||
viewer.showGrid.value = true
|
||||
viewer.cameraType.value = 'orthographic'
|
||||
viewer.fov.value = 60
|
||||
viewer.lightIntensity.value = 2
|
||||
viewer.backgroundImage.value = 'bg.jpg'
|
||||
viewer.backgroundRenderMode.value = 'panorama'
|
||||
viewer.upDirection.value = '+y'
|
||||
viewer.materialMode.value = 'normal'
|
||||
await nextTick()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
|
||||
'toastMessages.failedToToggleGrid'
|
||||
)
|
||||
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
|
||||
'toastMessages.failedToToggleCamera'
|
||||
)
|
||||
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
|
||||
'toastMessages.failedToUpdateFOV'
|
||||
)
|
||||
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
|
||||
'toastMessages.failedToUpdateLightIntensity'
|
||||
)
|
||||
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
|
||||
'toastMessages.failedToUpdateBackgroundImage'
|
||||
)
|
||||
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
|
||||
'toastMessages.failedToUpdateBackgroundRenderMode'
|
||||
)
|
||||
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
|
||||
'toastMessages.failedToUpdateUpDirection'
|
||||
)
|
||||
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
|
||||
'toastMessages.failedToUpdateMaterialMode'
|
||||
)
|
||||
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('exportModel', () => {
|
||||
@@ -582,43 +368,6 @@ describe('useLoad3dViewer', () => {
|
||||
|
||||
expect(mockLoad3d.updateStatusMouseOnViewer).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('handles animation controls and seek after progress is known', async () => {
|
||||
const eventHandlers: Record<string, (value: unknown) => void> = {}
|
||||
vi.mocked(mockLoad3d.addEventListener!).mockImplementation(
|
||||
(event: string, handler: unknown) => {
|
||||
eventHandlers[event] = handler as (value: unknown) => void
|
||||
}
|
||||
)
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
await viewer.initializeViewer(
|
||||
document.createElement('div'),
|
||||
mockSourceLoad3d as Load3d
|
||||
)
|
||||
|
||||
eventHandlers.animationListChange([{ name: 'Spin', index: 0 }])
|
||||
eventHandlers.animationProgressChange({
|
||||
progress: 25,
|
||||
currentTime: 1,
|
||||
duration: 8
|
||||
})
|
||||
viewer.playing.value = true
|
||||
viewer.selectedSpeed.value = 0
|
||||
const undefNum: unknown = undefined
|
||||
viewer.selectedAnimation.value = undefNum as number
|
||||
await nextTick()
|
||||
viewer.selectedSpeed.value = 2
|
||||
viewer.selectedAnimation.value = 0
|
||||
await nextTick()
|
||||
viewer.handleSeek(50)
|
||||
|
||||
expect(viewer.animations.value).toEqual([{ name: 'Spin', index: 0 }])
|
||||
expect(viewer.animationProgress.value).toBe(25)
|
||||
expect(mockLoad3d.toggleAnimation).toHaveBeenCalledWith(true)
|
||||
expect(mockLoad3d.setAnimationSpeed).toHaveBeenCalledWith(2)
|
||||
expect(mockLoad3d.updateSelectedAnimation).toHaveBeenCalledWith(0)
|
||||
expect(mockLoad3d.setAnimationTime).toHaveBeenCalledWith(4)
|
||||
})
|
||||
})
|
||||
|
||||
describe('restoreInitialState', () => {
|
||||
@@ -673,14 +422,6 @@ describe('useLoad3dViewer', () => {
|
||||
.futureField
|
||||
).toBe('preserve-me')
|
||||
})
|
||||
|
||||
it('does nothing in standalone mode', () => {
|
||||
const viewer = useLoad3dViewer()
|
||||
|
||||
viewer.restoreInitialState()
|
||||
|
||||
expect(viewer.needApplyChanges.value).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyChanges', () => {
|
||||
@@ -735,26 +476,6 @@ describe('useLoad3dViewer', () => {
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('applies without writing properties or dirtying when node state is absent', async () => {
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
await viewer.initializeViewer(
|
||||
document.createElement('div'),
|
||||
mockSourceLoad3d as Load3d
|
||||
)
|
||||
const undefProps: unknown = undefined
|
||||
mockNode.properties = undefProps as LGraphNode['properties']
|
||||
const undefGraph: unknown = undefined
|
||||
mockNode.graph = undefGraph as LGraphNode['graph']
|
||||
|
||||
const result = await viewer.applyChanges()
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(mockLoad3dService.copyLoad3dState).toHaveBeenLastCalledWith(
|
||||
mockLoad3d,
|
||||
mockSourceLoad3d
|
||||
)
|
||||
})
|
||||
|
||||
it('should preserve unknown fields on Model Config when applying', async () => {
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
@@ -857,37 +578,6 @@ describe('useLoad3dViewer', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('alerts when uploading without an active Load3d instance', async () => {
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
|
||||
await viewer.handleBackgroundImageUpdate(
|
||||
new File([''], 'test.jpg', { type: 'image/jpeg' })
|
||||
)
|
||||
|
||||
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
|
||||
'toastMessages.no3dScene'
|
||||
)
|
||||
expect(Load3dUtils.uploadFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves existing background state when upload returns no path', async () => {
|
||||
vi.mocked(Load3dUtils.uploadFile).mockResolvedValueOnce('')
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
await viewer.initializeViewer(
|
||||
document.createElement('div'),
|
||||
mockSourceLoad3d as Load3d
|
||||
)
|
||||
viewer.backgroundImage.value = 'existing.jpg'
|
||||
viewer.hasBackgroundImage.value = true
|
||||
|
||||
await viewer.handleBackgroundImageUpdate(
|
||||
new File([''], 'test.jpg', { type: 'image/jpeg' })
|
||||
)
|
||||
|
||||
expect(viewer.backgroundImage.value).toBe('existing.jpg')
|
||||
expect(viewer.hasBackgroundImage.value).toBe(true)
|
||||
})
|
||||
|
||||
it('should work in standalone mode without a node', async () => {
|
||||
vi.mocked(Load3dUtils.uploadFile).mockResolvedValueOnce(
|
||||
'uploaded-image.jpg'
|
||||
@@ -974,69 +664,6 @@ describe('useLoad3dViewer', () => {
|
||||
)
|
||||
expect(mockLoad3d.loadModel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('updates the model_file widget after a successful drop', async () => {
|
||||
const modelWidget = {
|
||||
name: 'model_file',
|
||||
value: '',
|
||||
options: { values: ['existing.glb'] }
|
||||
}
|
||||
mockNode.widgets = [modelWidget] as LGraphNode['widgets']
|
||||
vi.mocked(Load3dUtils.uploadFile).mockResolvedValueOnce('3d/new.glb')
|
||||
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
await viewer.initializeViewer(
|
||||
document.createElement('div'),
|
||||
mockSourceLoad3d as Load3d
|
||||
)
|
||||
|
||||
await viewer.handleModelDrop(new File([''], 'new.glb'))
|
||||
|
||||
expect(modelWidget.value).toBe('3d/new.glb')
|
||||
expect(modelWidget.options.values).toEqual(['existing.glb', '3d/new.glb'])
|
||||
})
|
||||
|
||||
it('does not duplicate model_file widget options', async () => {
|
||||
const modelWidget = {
|
||||
name: 'model_file',
|
||||
value: '',
|
||||
options: { values: ['3d/new.glb'] }
|
||||
}
|
||||
mockNode.widgets = [modelWidget] as LGraphNode['widgets']
|
||||
vi.mocked(Load3dUtils.uploadFile).mockResolvedValueOnce('3d/new.glb')
|
||||
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
await viewer.initializeViewer(
|
||||
document.createElement('div'),
|
||||
mockSourceLoad3d as Load3d
|
||||
)
|
||||
|
||||
await viewer.handleModelDrop(new File([''], 'new.glb'))
|
||||
|
||||
expect(modelWidget.options.values).toEqual(['3d/new.glb'])
|
||||
})
|
||||
|
||||
it('alerts when model loading throws', async () => {
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined)
|
||||
vi.mocked(Load3dUtils.uploadFile).mockResolvedValueOnce('3d/bad.glb')
|
||||
vi.mocked(mockLoad3d.loadModel!).mockRejectedValueOnce(
|
||||
new Error('bad model')
|
||||
)
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
await viewer.initializeViewer(
|
||||
document.createElement('div'),
|
||||
mockSourceLoad3d as Load3d
|
||||
)
|
||||
|
||||
await viewer.handleModelDrop(new File([''], 'bad.glb'))
|
||||
|
||||
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
|
||||
'toastMessages.failedToLoadModel'
|
||||
)
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('cleanup', () => {
|
||||
@@ -1067,26 +694,13 @@ describe('useLoad3dViewer', () => {
|
||||
expect(Load3d).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle missing node in node-mode initialization', async () => {
|
||||
const viewer = useLoad3dViewer()
|
||||
|
||||
await viewer.initializeViewer(
|
||||
document.createElement('div'),
|
||||
mockSourceLoad3d as Load3d
|
||||
)
|
||||
|
||||
expect(createLoad3d).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle orthographic camera', async () => {
|
||||
vi.mocked(mockSourceLoad3d.getCurrentCameraType!).mockReturnValue(
|
||||
'orthographic'
|
||||
)
|
||||
mockSourceLoad3d.cameraManager = fromPartial<Load3d['cameraManager']>({
|
||||
perspectiveCamera: fromPartial<
|
||||
Load3d['cameraManager']['perspectiveCamera']
|
||||
>({ fov: 75 })
|
||||
})
|
||||
mockSourceLoad3d.cameraManager = {
|
||||
perspectiveCamera: { fov: 75 }
|
||||
} as Partial<Load3d['cameraManager']> as Load3d['cameraManager']
|
||||
delete (mockNode.properties!['Camera Config'] as Record<string, unknown>)
|
||||
.cameraType
|
||||
|
||||
@@ -1111,96 +725,6 @@ describe('useLoad3dViewer', () => {
|
||||
})
|
||||
|
||||
describe('standalone mode persistence', () => {
|
||||
it('should handle missing standalone container ref', async () => {
|
||||
const viewer = useLoad3dViewer()
|
||||
|
||||
await viewer.initializeStandaloneViewer(null!, 'model.glb')
|
||||
|
||||
expect(createLoad3d).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('syncs pending hover state during standalone initialization', async () => {
|
||||
const viewer = useLoad3dViewer()
|
||||
viewer.handleMouseEnter()
|
||||
|
||||
await viewer.initializeStandaloneViewer(
|
||||
document.createElement('div'),
|
||||
'hover.glb'
|
||||
)
|
||||
|
||||
expect(mockLoad3d.updateStatusMouseOnViewer).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('shows an alert when first standalone load fails', async () => {
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined)
|
||||
vi.mocked(mockLoad3d.loadModel!).mockRejectedValueOnce(
|
||||
new Error('load failed')
|
||||
)
|
||||
const viewer = useLoad3dViewer()
|
||||
|
||||
await viewer.initializeStandaloneViewer(
|
||||
document.createElement('div'),
|
||||
'broken.glb'
|
||||
)
|
||||
|
||||
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
|
||||
'toastMessages.failedToLoadModel'
|
||||
)
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('shows an alert when reusing a standalone viewer fails to load the next model', async () => {
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined)
|
||||
const viewer = useLoad3dViewer()
|
||||
await viewer.initializeStandaloneViewer(
|
||||
document.createElement('div'),
|
||||
'first.glb'
|
||||
)
|
||||
vi.mocked(mockLoad3d.loadModel!).mockRejectedValueOnce(
|
||||
new Error('reload failed')
|
||||
)
|
||||
|
||||
await viewer.initializeStandaloneViewer(
|
||||
document.createElement('div'),
|
||||
'second.glb'
|
||||
)
|
||||
|
||||
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
|
||||
'Failed to load 3D model'
|
||||
)
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('restores cached standalone camera state', async () => {
|
||||
const cameraState = {
|
||||
position: { x: 2, y: 3, z: 4 },
|
||||
target: { x: 0, y: 0, z: 0 },
|
||||
zoom: 2,
|
||||
cameraType: 'perspective'
|
||||
}
|
||||
const cameraReturn: unknown = cameraState
|
||||
vi.mocked(mockLoad3d.getCameraState!).mockReturnValue(
|
||||
cameraReturn as CameraState
|
||||
)
|
||||
const viewer = useLoad3dViewer()
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await viewer.initializeStandaloneViewer(containerRef, 'camera.glb')
|
||||
viewer.cleanup()
|
||||
|
||||
const restoredViewer = useLoad3dViewer()
|
||||
await restoredViewer.initializeStandaloneViewer(
|
||||
containerRef,
|
||||
'camera.glb'
|
||||
)
|
||||
|
||||
expect(mockLoad3d.setCameraState).toHaveBeenCalledWith(cameraState)
|
||||
})
|
||||
|
||||
it('should save and restore configuration in standalone mode', async () => {
|
||||
const viewer = useLoad3dViewer()
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, defineComponent, nextTick, ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
import { useNodeHelpContent } from '@/composables/useNodeHelpContent'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
@@ -34,6 +33,12 @@ vi.mock('@/scripts/api', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
locale: ref('en')
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/types/nodeSource', () => ({
|
||||
NodeSourceType: {
|
||||
Core: 'core',
|
||||
@@ -47,23 +52,6 @@ vi.mock('@/types/nodeSource', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
const i18n = createI18n({ legacy: false, locale: 'en', messages: { en: {} } })
|
||||
|
||||
function withI18n<T>(fn: () => T): T {
|
||||
let result!: T
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup() {
|
||||
result = fn()
|
||||
return () => null
|
||||
}
|
||||
})
|
||||
)
|
||||
app.use(i18n)
|
||||
app.mount(document.createElement('div'))
|
||||
return result
|
||||
}
|
||||
|
||||
describe('useNodeHelpContent', () => {
|
||||
const mockCoreNode = createMockNode({
|
||||
name: 'TestNode',
|
||||
@@ -97,7 +85,7 @@ describe('useNodeHelpContent', () => {
|
||||
text: async () => '# Test'
|
||||
})
|
||||
|
||||
const { baseUrl } = withI18n(() => useNodeHelpContent(nodeRef))
|
||||
const { baseUrl } = useNodeHelpContent(nodeRef)
|
||||
await nextTick()
|
||||
|
||||
expect(baseUrl.value).toBe(`/docs/${mockCoreNode.name}/`)
|
||||
@@ -110,7 +98,7 @@ describe('useNodeHelpContent', () => {
|
||||
text: async () => '# Test'
|
||||
})
|
||||
|
||||
const { baseUrl } = withI18n(() => useNodeHelpContent(nodeRef))
|
||||
const { baseUrl } = useNodeHelpContent(nodeRef)
|
||||
await nextTick()
|
||||
|
||||
expect(baseUrl.value).toBe('/extensions/test_module/docs/')
|
||||
@@ -123,7 +111,7 @@ describe('useNodeHelpContent', () => {
|
||||
text: async () => '# Test Help\nThis is test help content'
|
||||
})
|
||||
|
||||
const { renderedHelpHtml } = withI18n(() => useNodeHelpContent(nodeRef))
|
||||
const { renderedHelpHtml } = useNodeHelpContent(nodeRef)
|
||||
await flushPromises()
|
||||
|
||||
expect(renderedHelpHtml.value).toContain('This is test help content')
|
||||
@@ -136,9 +124,7 @@ describe('useNodeHelpContent', () => {
|
||||
statusText: 'Not Found'
|
||||
})
|
||||
|
||||
const { error, renderedHelpHtml } = withI18n(() =>
|
||||
useNodeHelpContent(nodeRef)
|
||||
)
|
||||
const { error, renderedHelpHtml } = useNodeHelpContent(nodeRef)
|
||||
await flushPromises()
|
||||
|
||||
expect(error.value).toBe('Not Found')
|
||||
@@ -152,7 +138,7 @@ describe('useNodeHelpContent', () => {
|
||||
text: async () => ''
|
||||
})
|
||||
|
||||
const { renderedHelpHtml } = withI18n(() => useNodeHelpContent(nodeRef))
|
||||
const { renderedHelpHtml } = useNodeHelpContent(nodeRef)
|
||||
await flushPromises()
|
||||
|
||||
expect(renderedHelpHtml.value).toContain('alt="image"')
|
||||
@@ -165,7 +151,7 @@ describe('useNodeHelpContent', () => {
|
||||
text: async () => '<video src="video.mp4"></video>'
|
||||
})
|
||||
|
||||
const { renderedHelpHtml } = withI18n(() => useNodeHelpContent(nodeRef))
|
||||
const { renderedHelpHtml } = useNodeHelpContent(nodeRef)
|
||||
await flushPromises()
|
||||
|
||||
expect(renderedHelpHtml.value).toContain(
|
||||
@@ -180,7 +166,7 @@ describe('useNodeHelpContent', () => {
|
||||
text: async () => '<video src="video.mp4"></video>'
|
||||
})
|
||||
|
||||
const { renderedHelpHtml } = withI18n(() => useNodeHelpContent(nodeRef))
|
||||
const { renderedHelpHtml } = useNodeHelpContent(nodeRef)
|
||||
await flushPromises()
|
||||
|
||||
expect(renderedHelpHtml.value).toContain(
|
||||
@@ -192,7 +178,7 @@ describe('useNodeHelpContent', () => {
|
||||
const nodeRef = ref(mockCoreNode)
|
||||
mockFetch.mockImplementationOnce(() => new Promise(() => {})) // Never resolves
|
||||
|
||||
const { isLoading } = withI18n(() => useNodeHelpContent(nodeRef))
|
||||
const { isLoading } = useNodeHelpContent(nodeRef)
|
||||
await nextTick()
|
||||
|
||||
expect(isLoading.value).toBe(true)
|
||||
@@ -210,7 +196,7 @@ describe('useNodeHelpContent', () => {
|
||||
text: async () => '# Fallback content'
|
||||
})
|
||||
|
||||
withI18n(() => useNodeHelpContent(nodeRef))
|
||||
useNodeHelpContent(nodeRef)
|
||||
await flushPromises()
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2)
|
||||
@@ -230,7 +216,7 @@ describe('useNodeHelpContent', () => {
|
||||
'<video><source src="video.mp4" type="video/mp4" /></video>'
|
||||
})
|
||||
|
||||
const { renderedHelpHtml } = withI18n(() => useNodeHelpContent(nodeRef))
|
||||
const { renderedHelpHtml } = useNodeHelpContent(nodeRef)
|
||||
await flushPromises()
|
||||
|
||||
expect(renderedHelpHtml.value).toContain(
|
||||
@@ -246,7 +232,7 @@ describe('useNodeHelpContent', () => {
|
||||
'<video><source src="video.webm" type="video/webm" /></video>'
|
||||
})
|
||||
|
||||
const { renderedHelpHtml } = withI18n(() => useNodeHelpContent(nodeRef))
|
||||
const { renderedHelpHtml } = useNodeHelpContent(nodeRef)
|
||||
await flushPromises()
|
||||
|
||||
expect(renderedHelpHtml.value).toContain(
|
||||
@@ -261,7 +247,7 @@ describe('useNodeHelpContent', () => {
|
||||
text: async () => '# Test\n<img src="image.png" alt="Test image">'
|
||||
})
|
||||
|
||||
const { renderedHelpHtml } = withI18n(() => useNodeHelpContent(nodeRef))
|
||||
const { renderedHelpHtml } = useNodeHelpContent(nodeRef)
|
||||
await flushPromises()
|
||||
|
||||
expect(renderedHelpHtml.value).toContain(
|
||||
@@ -277,7 +263,7 @@ describe('useNodeHelpContent', () => {
|
||||
text: async () => '# Test\n<img src="image.png" alt="Test image">'
|
||||
})
|
||||
|
||||
const { renderedHelpHtml } = withI18n(() => useNodeHelpContent(nodeRef))
|
||||
const { renderedHelpHtml } = useNodeHelpContent(nodeRef)
|
||||
await flushPromises()
|
||||
|
||||
expect(renderedHelpHtml.value).toContain(
|
||||
@@ -293,7 +279,7 @@ describe('useNodeHelpContent', () => {
|
||||
text: async () => '<img src="/absolute/image.png" alt="Absolute">'
|
||||
})
|
||||
|
||||
const { renderedHelpHtml } = withI18n(() => useNodeHelpContent(nodeRef))
|
||||
const { renderedHelpHtml } = useNodeHelpContent(nodeRef)
|
||||
await flushPromises()
|
||||
|
||||
expect(renderedHelpHtml.value).toContain('src="/absolute/image.png"')
|
||||
@@ -308,7 +294,7 @@ describe('useNodeHelpContent', () => {
|
||||
'<img src="https://example.com/image.png" alt="External">'
|
||||
})
|
||||
|
||||
const { renderedHelpHtml } = withI18n(() => useNodeHelpContent(nodeRef))
|
||||
const { renderedHelpHtml } = useNodeHelpContent(nodeRef)
|
||||
await flushPromises()
|
||||
|
||||
expect(renderedHelpHtml.value).toContain(
|
||||
@@ -338,7 +324,7 @@ Testing quote styles in properly formed HTML:
|
||||
The MEDIA_SRC_REGEX handles both single and double quotes in img, video and source tags.`
|
||||
})
|
||||
|
||||
const { renderedHelpHtml } = withI18n(() => useNodeHelpContent(nodeRef))
|
||||
const { renderedHelpHtml } = useNodeHelpContent(nodeRef)
|
||||
await flushPromises()
|
||||
|
||||
// All media src attributes should be prefixed correctly
|
||||
@@ -377,7 +363,7 @@ The MEDIA_SRC_REGEX handles both single and double quotes in img, video and sour
|
||||
text: async () => '# Second node content'
|
||||
})
|
||||
|
||||
const { helpContent } = withI18n(() => useNodeHelpContent(nodeRef))
|
||||
const { helpContent } = useNodeHelpContent(nodeRef)
|
||||
await nextTick()
|
||||
|
||||
// Change node before first request completes
|
||||
@@ -395,58 +381,4 @@ The MEDIA_SRC_REGEX handles both single and double quotes in img, video and sour
|
||||
// Should have second node's content, not first
|
||||
expect(helpContent.value).toBe('# Second node content')
|
||||
})
|
||||
|
||||
it('returns empty state when no node is selected', async () => {
|
||||
const nodeRef = ref<ComfyNodeDefImpl | null>(null)
|
||||
|
||||
const { baseUrl, helpContent, isLoading, error } = withI18n(() =>
|
||||
useNodeHelpContent(nodeRef)
|
||||
)
|
||||
await nextTick()
|
||||
|
||||
expect(baseUrl.value).toBe('')
|
||||
expect(helpContent.value).toBe('')
|
||||
expect(isLoading.value).toBe(false)
|
||||
expect(error.value).toBeNull()
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses stringified non-error rejections with the node description', async () => {
|
||||
const nodeRef = ref(mockCoreNode)
|
||||
mockFetch.mockRejectedValueOnce('offline')
|
||||
|
||||
const { error, helpContent } = withI18n(() => useNodeHelpContent(nodeRef))
|
||||
await flushPromises()
|
||||
|
||||
expect(error.value).toBe('offline')
|
||||
expect(helpContent.value).toBe(mockCoreNode.description)
|
||||
})
|
||||
|
||||
it('ignores stale rejected requests after the node changes', async () => {
|
||||
const nodeRef = ref(mockCoreNode)
|
||||
let rejectFirst: (reason?: unknown) => void
|
||||
const firstRequest = new Promise((_resolve, reject) => {
|
||||
rejectFirst = reject
|
||||
})
|
||||
|
||||
mockFetch
|
||||
.mockImplementationOnce(() => firstRequest)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: async () => '# Current node content'
|
||||
})
|
||||
|
||||
const { error, helpContent } = withI18n(() => useNodeHelpContent(nodeRef))
|
||||
await nextTick()
|
||||
|
||||
nodeRef.value = mockCustomNode
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
|
||||
rejectFirst!(new Error('stale failure'))
|
||||
await flushPromises()
|
||||
|
||||
expect(error.value).toBeNull()
|
||||
expect(helpContent.value).toBe('# Current node content')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
LGraphCanvas,
|
||||
@@ -24,7 +23,6 @@ import {
|
||||
pasteVideoNodes,
|
||||
usePaste
|
||||
} from './usePaste'
|
||||
import { shouldIgnoreCopyPaste } from '@/workbench/eventHelpers'
|
||||
|
||||
function createMockNode(): LGraphNode {
|
||||
return createMockLGraphNode({
|
||||
@@ -61,19 +59,19 @@ function createDataTransfer(files: File[] = []): DataTransfer {
|
||||
return dataTransfer
|
||||
}
|
||||
|
||||
const mockCanvas = fromPartial<LGraphCanvas>({
|
||||
const mockCanvas = {
|
||||
current_node: null as LGraphNode | null,
|
||||
graph: fromPartial<LGraph>({
|
||||
graph: {
|
||||
add: vi.fn(),
|
||||
change: vi.fn()
|
||||
}),
|
||||
} as Partial<LGraph> as LGraph,
|
||||
graph_mouse: [100, 200],
|
||||
pasteFromClipboard: vi.fn(),
|
||||
_deserializeItems: vi.fn()
|
||||
})
|
||||
} as Partial<LGraphCanvas> as LGraphCanvas
|
||||
|
||||
const mockCanvasStore = {
|
||||
canvas: mockCanvas as LGraphCanvas | null,
|
||||
canvas: mockCanvas,
|
||||
getCanvas: vi.fn(() => mockCanvas)
|
||||
}
|
||||
|
||||
@@ -141,17 +139,6 @@ describe('pasteImageNode', () => {
|
||||
expect(mockNode.pasteFile).toHaveBeenCalledWith(file)
|
||||
})
|
||||
|
||||
it('returns null when image node creation fails', async () => {
|
||||
vi.mocked(createNode).mockResolvedValue(null as never)
|
||||
|
||||
const file = createImageFile()
|
||||
const dataTransfer = createDataTransfer([file])
|
||||
|
||||
await expect(
|
||||
pasteImageNode(mockCanvas, dataTransfer.items)
|
||||
).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('should use existing image node when provided', async () => {
|
||||
const mockNode = createMockNode()
|
||||
const file = createImageFile()
|
||||
@@ -229,14 +216,6 @@ describe('pasteImageNodes', () => {
|
||||
expect(createNode).not.toHaveBeenCalled()
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('omits files whose image node creation fails', async () => {
|
||||
vi.mocked(createNode).mockResolvedValue(null as never)
|
||||
|
||||
const result = await pasteImageNodes(mockCanvas, [createImageFile()])
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('pasteAudioNode', () => {
|
||||
@@ -257,17 +236,6 @@ describe('pasteAudioNode', () => {
|
||||
expect(mockNode.pasteFile).toHaveBeenCalledWith(file)
|
||||
})
|
||||
|
||||
it('returns null when audio node creation fails', async () => {
|
||||
vi.mocked(createNode).mockResolvedValue(null as never)
|
||||
|
||||
const file = createAudioFile()
|
||||
const dataTransfer = createDataTransfer([file])
|
||||
|
||||
await expect(
|
||||
pasteAudioNode(mockCanvas, dataTransfer.items)
|
||||
).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('should use existing audio node when provided', async () => {
|
||||
const mockNode = createMockNode()
|
||||
const file = createAudioFile()
|
||||
@@ -344,14 +312,6 @@ describe('pasteAudioNodes', () => {
|
||||
expect(createNode).toHaveBeenCalledTimes(1)
|
||||
expect(result).toEqual([mockNode])
|
||||
})
|
||||
|
||||
it('omits files whose audio node creation fails', async () => {
|
||||
vi.mocked(createNode).mockResolvedValue(null as never)
|
||||
|
||||
const result = await pasteAudioNodes(mockCanvas, [createAudioFile()])
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('pasteVideoNode', () => {
|
||||
@@ -372,17 +332,6 @@ describe('pasteVideoNode', () => {
|
||||
expect(mockNode.pasteFile).toHaveBeenCalledWith(file)
|
||||
})
|
||||
|
||||
it('returns null when video node creation fails', async () => {
|
||||
vi.mocked(createNode).mockResolvedValue(null as never)
|
||||
|
||||
const file = createVideoFile()
|
||||
const dataTransfer = createDataTransfer([file])
|
||||
|
||||
await expect(
|
||||
pasteVideoNode(mockCanvas, dataTransfer.items)
|
||||
).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('should use existing video node when provided', async () => {
|
||||
const mockNode = createMockNode()
|
||||
const file = createVideoFile()
|
||||
@@ -459,23 +408,13 @@ describe('pasteVideoNodes', () => {
|
||||
expect(createNode).toHaveBeenCalledTimes(1)
|
||||
expect(result).toEqual([mockNode])
|
||||
})
|
||||
|
||||
it('omits files whose video node creation fails', async () => {
|
||||
vi.mocked(createNode).mockResolvedValue(null as never)
|
||||
|
||||
const result = await pasteVideoNodes(mockCanvas, [createVideoFile()])
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('usePaste', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCanvasStore.canvas = mockCanvas
|
||||
mockCanvas.current_node = null
|
||||
mockWorkspaceStore.shiftDown = false
|
||||
vi.mocked(shouldIgnoreCopyPaste).mockReturnValue(false)
|
||||
vi.mocked(mockCanvas.graph!.add).mockImplementation(
|
||||
(node: LGraphNode | LGraphGroup | null) => node as LGraphNode
|
||||
)
|
||||
@@ -605,31 +544,6 @@ describe('usePaste', () => {
|
||||
expect(createNode).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores paste when the target owns native copy paste', () => {
|
||||
vi.mocked(shouldIgnoreCopyPaste).mockReturnValue(true)
|
||||
|
||||
usePaste()
|
||||
|
||||
const dataTransfer = createDataTransfer([createImageFile()])
|
||||
const event = new ClipboardEvent('paste', { clipboardData: dataTransfer })
|
||||
document.dispatchEvent(event)
|
||||
|
||||
expect(createNode).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores paste when the canvas is unavailable', () => {
|
||||
mockCanvasStore.canvas = null
|
||||
|
||||
usePaste()
|
||||
|
||||
const dataTransfer = createDataTransfer([createImageFile()])
|
||||
const event = new ClipboardEvent('paste', { clipboardData: dataTransfer })
|
||||
document.dispatchEvent(event)
|
||||
|
||||
expect(createNode).not.toHaveBeenCalled()
|
||||
expect(mockCanvas.pasteFromClipboard).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should use existing image node when selected', () => {
|
||||
const mockNode = createMockLGraphNode({
|
||||
is_selected: true,
|
||||
@@ -682,66 +596,6 @@ describe('usePaste', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to litegraph paste when metadata cannot be decoded', async () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
usePaste()
|
||||
|
||||
const dataTransfer = new DataTransfer()
|
||||
dataTransfer.setData(
|
||||
'text/html',
|
||||
`<div data-metadata="${btoa('{')}"></div>`
|
||||
)
|
||||
|
||||
const event = new ClipboardEvent('paste', { clipboardData: dataTransfer })
|
||||
document.dispatchEvent(event)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockCanvas._deserializeItems).not.toHaveBeenCalled()
|
||||
expect(mockCanvas.pasteFromClipboard).toHaveBeenCalled()
|
||||
})
|
||||
errorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('leaves plain text paste alone in text inputs', () => {
|
||||
usePaste()
|
||||
|
||||
const input = document.createElement('input')
|
||||
input.type = 'text'
|
||||
document.body.append(input)
|
||||
const dataTransfer = new DataTransfer()
|
||||
dataTransfer.setData('text/plain', 'plain text')
|
||||
|
||||
input.dispatchEvent(
|
||||
new ClipboardEvent('paste', {
|
||||
clipboardData: dataTransfer,
|
||||
bubbles: true
|
||||
})
|
||||
)
|
||||
|
||||
expect(mockCanvas.pasteFromClipboard).not.toHaveBeenCalled()
|
||||
input.remove()
|
||||
})
|
||||
|
||||
it('leaves plain text paste alone in textareas', () => {
|
||||
usePaste()
|
||||
|
||||
const textarea = document.createElement('textarea')
|
||||
document.body.append(textarea)
|
||||
const dataTransfer = new DataTransfer()
|
||||
dataTransfer.setData('text/plain', 'plain text')
|
||||
|
||||
textarea.dispatchEvent(
|
||||
new ClipboardEvent('paste', {
|
||||
clipboardData: dataTransfer,
|
||||
bubbles: true
|
||||
})
|
||||
)
|
||||
|
||||
expect(mockCanvas.pasteFromClipboard).not.toHaveBeenCalled()
|
||||
textarea.remove()
|
||||
})
|
||||
|
||||
it('should skip node metadata paste when a media node is selected', async () => {
|
||||
const mockNode = createMockLGraphNode({
|
||||
is_selected: true,
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { useProgressBarBackground } from './useProgressBarBackground'
|
||||
|
||||
describe('useProgressBarBackground', () => {
|
||||
it('identifies finite progress values', () => {
|
||||
const { hasProgressPercent, hasAnyProgressPercent } =
|
||||
useProgressBarBackground()
|
||||
|
||||
expect(hasProgressPercent(undefined)).toBe(false)
|
||||
expect(hasProgressPercent(Number.NaN)).toBe(false)
|
||||
expect(hasProgressPercent(0)).toBe(true)
|
||||
expect(hasAnyProgressPercent(undefined, Number.POSITIVE_INFINITY)).toBe(
|
||||
false
|
||||
)
|
||||
expect(hasAnyProgressPercent(undefined, 42)).toBe(true)
|
||||
})
|
||||
|
||||
it('clamps progress styles to the valid percent range', () => {
|
||||
const { progressPercentStyle } = useProgressBarBackground()
|
||||
|
||||
expect(progressPercentStyle(undefined)).toBeUndefined()
|
||||
expect(progressPercentStyle(-10)).toEqual({ width: '0%' })
|
||||
expect(progressPercentStyle(125)).toEqual({ width: '100%' })
|
||||
expect(progressPercentStyle(37)).toEqual({ width: '37%' })
|
||||
})
|
||||
})
|
||||
@@ -1,61 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, reactive } from 'vue'
|
||||
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useRefreshableSelection } from '@/composables/useRefreshableSelection'
|
||||
|
||||
const mockCanvasStore = reactive({
|
||||
selectedItems: [] as unknown[]
|
||||
})
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => mockCanvasStore
|
||||
}))
|
||||
|
||||
function makeNode(widgets?: unknown[]): LGraphNode {
|
||||
const node = new LGraphNode('Test')
|
||||
node.widgets = widgets as LGraphNode['widgets']
|
||||
return node
|
||||
}
|
||||
|
||||
describe('useRefreshableSelection', () => {
|
||||
beforeEach(() => {
|
||||
mockCanvasStore.selectedItems = []
|
||||
})
|
||||
|
||||
it('does nothing when no selected widget is refreshable', async () => {
|
||||
const selection = useRefreshableSelection()
|
||||
|
||||
await selection.refreshSelected()
|
||||
|
||||
expect(selection.isRefreshable.value).toBe(false)
|
||||
})
|
||||
|
||||
it('refreshes selected widgets that expose a refresh function', async () => {
|
||||
const refresh = vi.fn()
|
||||
const ignoredRefresh = vi.fn()
|
||||
mockCanvasStore.selectedItems = [
|
||||
makeNode([{ refresh }, { refresh: 'not callable' }, null]),
|
||||
{ widgets: [{ refresh: ignoredRefresh }] }
|
||||
]
|
||||
|
||||
const selection = useRefreshableSelection()
|
||||
await nextTick()
|
||||
|
||||
expect(selection.isRefreshable.value).toBe(true)
|
||||
|
||||
await selection.refreshSelected()
|
||||
|
||||
expect(refresh).toHaveBeenCalledOnce()
|
||||
expect(ignoredRefresh).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('treats selected nodes without widgets as not refreshable', async () => {
|
||||
mockCanvasStore.selectedItems = [makeNode()]
|
||||
|
||||
const selection = useRefreshableSelection()
|
||||
await nextTick()
|
||||
|
||||
expect(selection.isRefreshable.value).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
@@ -20,13 +20,6 @@ vi.mock('@vueuse/core', () => ({
|
||||
}))
|
||||
|
||||
describe('useServerLogs', () => {
|
||||
const listenerFor = <T>(eventType: string) =>
|
||||
vi
|
||||
.mocked(useEventListener)
|
||||
.mock.calls.find(([, type]) => type === eventType)?.[2] as
|
||||
| ((event: CustomEvent<T>) => void)
|
||||
| undefined
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
@@ -87,7 +80,8 @@ describe('useServerLogs', () => {
|
||||
|
||||
// Simulate receiving a log event
|
||||
const mockEvent = new CustomEvent('logs', {
|
||||
detail: fromPartial<LogsWsMessage>({
|
||||
detail: fromAny<LogsWsMessage, unknown>({
|
||||
type: 'logs',
|
||||
entries: [{ m: 'Log message 1' }, { m: 'Log message 2' }]
|
||||
})
|
||||
}) as CustomEvent<LogsWsMessage>
|
||||
@@ -110,7 +104,8 @@ describe('useServerLogs', () => {
|
||||
) => void
|
||||
|
||||
const mockEvent = new CustomEvent('logs', {
|
||||
detail: fromPartial<LogsWsMessage>({
|
||||
detail: fromAny<LogsWsMessage, unknown>({
|
||||
type: 'logs',
|
||||
entries: [
|
||||
{ m: 'Log message 1 dont remove me' },
|
||||
{ m: 'remove me' },
|
||||
@@ -124,127 +119,4 @@ describe('useServerLogs', () => {
|
||||
|
||||
expect(logs.value).toEqual(['Log message 1 dont remove me', ''])
|
||||
})
|
||||
|
||||
it('only captures logs while the matching task is active', async () => {
|
||||
const { logs, startListening } = useServerLogs({ ui_id: 'task-1' })
|
||||
|
||||
await startListening()
|
||||
|
||||
expect(vi.mocked(useEventListener)).toHaveBeenCalledWith(
|
||||
api,
|
||||
'cm-task-started',
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(vi.mocked(useEventListener)).toHaveBeenCalledWith(
|
||||
api,
|
||||
'cm-task-completed',
|
||||
expect.any(Function)
|
||||
)
|
||||
|
||||
const onLogs = listenerFor<LogsWsMessage>('logs')
|
||||
const onTaskStarted = listenerFor<{ ui_id: string }>('cm-task-started')
|
||||
const onTaskDone = listenerFor<{ ui_id: string }>('cm-task-completed')
|
||||
|
||||
onLogs?.(
|
||||
new CustomEvent('logs', {
|
||||
detail: fromPartial<LogsWsMessage>({
|
||||
entries: [{ m: 'before start' }]
|
||||
})
|
||||
})
|
||||
)
|
||||
onTaskStarted?.(
|
||||
new CustomEvent('cm-task-started', { detail: { ui_id: 'other-task' } })
|
||||
)
|
||||
onLogs?.(
|
||||
new CustomEvent('logs', {
|
||||
detail: fromPartial<LogsWsMessage>({
|
||||
entries: [{ m: 'wrong task' }]
|
||||
})
|
||||
})
|
||||
)
|
||||
onTaskStarted?.(
|
||||
new CustomEvent('cm-task-started', { detail: { ui_id: 'task-1' } })
|
||||
)
|
||||
onLogs?.(
|
||||
new CustomEvent('logs', {
|
||||
detail: fromPartial<LogsWsMessage>({
|
||||
entries: [{ m: 'captured' }]
|
||||
})
|
||||
})
|
||||
)
|
||||
onTaskDone?.(
|
||||
new CustomEvent('cm-task-completed', { detail: { ui_id: 'other-task' } })
|
||||
)
|
||||
onLogs?.(
|
||||
new CustomEvent('logs', {
|
||||
detail: fromPartial<LogsWsMessage>({
|
||||
entries: [{ m: 'still active' }]
|
||||
})
|
||||
})
|
||||
)
|
||||
onTaskDone?.(
|
||||
new CustomEvent('cm-task-completed', { detail: { ui_id: 'task-1' } })
|
||||
)
|
||||
onLogs?.(
|
||||
new CustomEvent('logs', {
|
||||
detail: fromPartial<LogsWsMessage>({
|
||||
entries: [{ m: 'after done' }]
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
expect(logs.value).toEqual(['captured', 'still active'])
|
||||
})
|
||||
|
||||
it('ignores invalid and empty log events', async () => {
|
||||
const { logs, startListening } = useServerLogs()
|
||||
|
||||
await startListening()
|
||||
|
||||
const onLogs = listenerFor<LogsWsMessage>('logs')
|
||||
|
||||
onLogs?.(
|
||||
new CustomEvent('not-logs', {
|
||||
detail: fromPartial<LogsWsMessage>({
|
||||
entries: [{ m: 'wrong event' }]
|
||||
})
|
||||
})
|
||||
)
|
||||
onLogs?.(
|
||||
new CustomEvent('logs', {
|
||||
detail: fromPartial<LogsWsMessage>({
|
||||
entries: []
|
||||
})
|
||||
})
|
||||
)
|
||||
onLogs?.(
|
||||
new CustomEvent('logs', {
|
||||
detail: fromPartial<LogsWsMessage>({
|
||||
entries: [{ m: ' ' }]
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
expect(logs.value).toEqual([])
|
||||
})
|
||||
|
||||
it('stops every registered listener', async () => {
|
||||
const stopLogs = vi.fn()
|
||||
const stopTaskStarted = vi.fn()
|
||||
const stopTaskDone = vi.fn()
|
||||
vi.mocked(useEventListener)
|
||||
.mockReturnValueOnce(stopLogs)
|
||||
.mockReturnValueOnce(stopTaskStarted)
|
||||
.mockReturnValueOnce(stopTaskDone)
|
||||
|
||||
const { startListening, stopListening } = useServerLogs({ ui_id: 'task-1' })
|
||||
|
||||
await startListening()
|
||||
await stopListening()
|
||||
|
||||
expect(stopLogs).toHaveBeenCalledTimes(1)
|
||||
expect(stopTaskStarted).toHaveBeenCalledTimes(1)
|
||||
expect(stopTaskDone).toHaveBeenCalledTimes(1)
|
||||
expect(api.subscribeLogs).toHaveBeenLastCalledWith(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -248,158 +248,6 @@ describe('useTemplateFiltering', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('accepts plain template arrays and treats invalid ref values as empty', () => {
|
||||
const plain = useTemplateFiltering([
|
||||
{
|
||||
name: 'plain-template',
|
||||
description: 'Plain template',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png'
|
||||
}
|
||||
])
|
||||
|
||||
expect(
|
||||
plain.filteredTemplates.value.map((template) => template.name)
|
||||
).toEqual(['plain-template'])
|
||||
|
||||
const notAnArrayRef: unknown = ref('not-an-array')
|
||||
const invalid = useTemplateFiltering(
|
||||
notAnArrayRef as Parameters<typeof useTemplateFiltering>[0]
|
||||
)
|
||||
|
||||
expect(invalid.filteredTemplates.value).toEqual([])
|
||||
expect(invalid.totalCount.value).toBe(0)
|
||||
})
|
||||
|
||||
it('ignores malformed models and tags while applying active filters', () => {
|
||||
const fluxString: unknown = 'Flux'
|
||||
const videoString: unknown = 'Video'
|
||||
const templates = ref<TemplateInfo[]>([
|
||||
{
|
||||
name: 'model-template',
|
||||
description: 'Model template',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png',
|
||||
models: ['Flux'],
|
||||
tags: ['Video']
|
||||
},
|
||||
{
|
||||
name: 'missing-models',
|
||||
description: 'Missing models',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png',
|
||||
models: fluxString as string[],
|
||||
tags: videoString as string[]
|
||||
}
|
||||
])
|
||||
|
||||
const {
|
||||
availableModels,
|
||||
availableUseCases,
|
||||
selectedModels,
|
||||
selectedUseCases,
|
||||
filteredTemplates
|
||||
} = useTemplateFiltering(templates)
|
||||
|
||||
expect(availableModels.value).toEqual(['Flux'])
|
||||
expect(availableUseCases.value).toEqual(['Video'])
|
||||
|
||||
selectedModels.value = ['Flux']
|
||||
selectedUseCases.value = ['Video']
|
||||
|
||||
expect(filteredTemplates.value.map((template) => template.name)).toEqual([
|
||||
'model-template'
|
||||
])
|
||||
})
|
||||
|
||||
it('returns no templates for unknown runs-on filters', () => {
|
||||
const templates = ref<TemplateInfo[]>([
|
||||
{
|
||||
name: 'template',
|
||||
description: 'Template',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png'
|
||||
}
|
||||
])
|
||||
|
||||
const { selectedRunsOn, filteredTemplates } =
|
||||
useTemplateFiltering(templates)
|
||||
|
||||
selectedRunsOn.value = ['Unknown target']
|
||||
|
||||
expect(filteredTemplates.value).toEqual([])
|
||||
})
|
||||
|
||||
it('supports recommended and popular score sorting', async () => {
|
||||
defaultRankingStore.computeDefaultScore.mockImplementation(
|
||||
(_date?: string, rank?: number) => rank ?? 0
|
||||
)
|
||||
defaultRankingStore.computePopularScore.mockImplementation(
|
||||
(_date?: string, usage?: number) => usage ?? 0
|
||||
)
|
||||
const templates = ref<TemplateInfo[]>([
|
||||
{
|
||||
name: 'low',
|
||||
description: 'Low score',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png',
|
||||
searchRank: 1,
|
||||
usage: 10
|
||||
},
|
||||
{
|
||||
name: 'high',
|
||||
description: 'High score',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png',
|
||||
searchRank: 9,
|
||||
usage: 1
|
||||
}
|
||||
])
|
||||
|
||||
const { sortBy, filteredTemplates } = useTemplateFiltering(templates)
|
||||
|
||||
sortBy.value = 'recommended'
|
||||
await nextTick()
|
||||
expect(filteredTemplates.value.map((template) => template.name)).toEqual([
|
||||
'high',
|
||||
'low'
|
||||
])
|
||||
|
||||
sortBy.value = 'popular'
|
||||
await nextTick()
|
||||
expect(filteredTemplates.value.map((template) => template.name)).toEqual([
|
||||
'low',
|
||||
'high'
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps equal and missing model sizes stable for size sorting', async () => {
|
||||
const templates = ref<TemplateInfo[]>([
|
||||
{
|
||||
name: 'first',
|
||||
description: 'First',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png'
|
||||
},
|
||||
{
|
||||
name: 'second',
|
||||
description: 'Second',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png'
|
||||
}
|
||||
])
|
||||
|
||||
const { sortBy, filteredTemplates } = useTemplateFiltering(templates)
|
||||
|
||||
sortBy.value = 'model-size-low-to-high'
|
||||
await nextTick()
|
||||
|
||||
expect(filteredTemplates.value.map((template) => template.name)).toEqual([
|
||||
'first',
|
||||
'second'
|
||||
])
|
||||
})
|
||||
|
||||
describe('loadFuseOptions', () => {
|
||||
it('updates fuseOptions when getFuseOptions returns valid options', async () => {
|
||||
const templates = ref<TemplateInfo[]>([
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
import { ref } from 'vue'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { useTreeExpansion } from '@/composables/useTreeExpansion'
|
||||
import type { TreeNode } from '@/types/treeExplorerTypes'
|
||||
|
||||
function node(over: Partial<TreeNode>): TreeNode {
|
||||
return over as TreeNode
|
||||
}
|
||||
|
||||
// root ─┬─ a ── a1 (leaf)
|
||||
// └─ b (leaf)
|
||||
function sampleTree() {
|
||||
const a1 = node({ key: 'a1', leaf: true })
|
||||
const a = node({ key: 'a', leaf: false, children: [a1] })
|
||||
const b = node({ key: 'b', leaf: true })
|
||||
const root = node({ key: 'root', leaf: false, children: [a, b] })
|
||||
return { root, a, a1, b }
|
||||
}
|
||||
|
||||
describe('useTreeExpansion', () => {
|
||||
it('toggleNode adds then removes a node key', () => {
|
||||
const expandedKeys = ref<Record<string, boolean>>({})
|
||||
const { toggleNode } = useTreeExpansion(expandedKeys)
|
||||
const n = node({ key: 'x' })
|
||||
|
||||
toggleNode(n)
|
||||
expect(expandedKeys.value).toEqual({ x: true })
|
||||
|
||||
toggleNode(n)
|
||||
expect(expandedKeys.value).toEqual({})
|
||||
})
|
||||
|
||||
it('toggleNode ignores nodes without a string key', () => {
|
||||
const expandedKeys = ref<Record<string, boolean>>({})
|
||||
const { toggleNode } = useTreeExpansion(expandedKeys)
|
||||
|
||||
toggleNode(node({ key: undefined }))
|
||||
|
||||
expect(expandedKeys.value).toEqual({})
|
||||
})
|
||||
|
||||
it('expandNode expands the node and all non-leaf descendants only', () => {
|
||||
const expandedKeys = ref<Record<string, boolean>>({})
|
||||
const { expandNode } = useTreeExpansion(expandedKeys)
|
||||
const { root } = sampleTree()
|
||||
|
||||
expandNode(root)
|
||||
|
||||
// root and a are folders; a1 and b are leaves and must be skipped
|
||||
expect(expandedKeys.value).toEqual({ root: true, a: true })
|
||||
})
|
||||
|
||||
it('expandNode does nothing for a leaf node', () => {
|
||||
const expandedKeys = ref<Record<string, boolean>>({})
|
||||
const { expandNode } = useTreeExpansion(expandedKeys)
|
||||
|
||||
expandNode(node({ key: 'leaf', leaf: true }))
|
||||
|
||||
expect(expandedKeys.value).toEqual({})
|
||||
})
|
||||
|
||||
it('collapseNode removes the node and its non-leaf descendants', () => {
|
||||
const expandedKeys = ref<Record<string, boolean>>({
|
||||
root: true,
|
||||
a: true,
|
||||
stray: true
|
||||
})
|
||||
const { collapseNode } = useTreeExpansion(expandedKeys)
|
||||
const { root } = sampleTree()
|
||||
|
||||
collapseNode(root)
|
||||
|
||||
expect(expandedKeys.value).toEqual({ stray: true })
|
||||
})
|
||||
|
||||
it('toggleNodeRecursive expands when collapsed and collapses when expanded', () => {
|
||||
const expandedKeys = ref<Record<string, boolean>>({})
|
||||
const { toggleNodeRecursive } = useTreeExpansion(expandedKeys)
|
||||
const { root } = sampleTree()
|
||||
|
||||
toggleNodeRecursive(root)
|
||||
expect(expandedKeys.value).toEqual({ root: true, a: true })
|
||||
|
||||
toggleNodeRecursive(root)
|
||||
expect(expandedKeys.value).toEqual({})
|
||||
})
|
||||
|
||||
it('toggleNodeOnEvent toggles recursively with ctrl and singly without', () => {
|
||||
const expandedKeys = ref<Record<string, boolean>>({})
|
||||
const { toggleNodeOnEvent } = useTreeExpansion(expandedKeys)
|
||||
const { root } = sampleTree()
|
||||
|
||||
toggleNodeOnEvent(new KeyboardEvent('keydown', { ctrlKey: true }), root)
|
||||
expect(expandedKeys.value).toEqual({ root: true, a: true })
|
||||
|
||||
// Plain toggle removes only the node's own key, leaving descendants
|
||||
toggleNodeOnEvent(new MouseEvent('click'), root)
|
||||
expect(expandedKeys.value).toEqual({ a: true })
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraph } from '@/lib/litegraph/src/LGraph'
|
||||
@@ -87,7 +87,7 @@ function makeMediaCombo(
|
||||
value: string,
|
||||
options: string[] = []
|
||||
): IComboWidget {
|
||||
return fromPartial<IComboWidget>({
|
||||
return fromAny<IComboWidget, unknown>({
|
||||
type: 'combo',
|
||||
name,
|
||||
value,
|
||||
@@ -102,20 +102,17 @@ function makeMediaNode(
|
||||
mode: number = 0,
|
||||
executionId?: string
|
||||
): LGraphNode {
|
||||
const node: unknown = {
|
||||
return fromAny<LGraphNode, unknown>({
|
||||
id,
|
||||
type,
|
||||
widgets,
|
||||
mode,
|
||||
_testExecutionId: executionId ?? String(id)
|
||||
}
|
||||
return node as LGraphNode & { _testExecutionId?: string }
|
||||
})
|
||||
}
|
||||
|
||||
function makeGraph(nodes: LGraphNode[]): LGraph {
|
||||
return fromPartial<LGraph & { _testNodes: LGraphNode[] }>({
|
||||
_testNodes: nodes
|
||||
})
|
||||
return fromAny<LGraph, unknown>({ _testNodes: nodes })
|
||||
}
|
||||
|
||||
function makeAsset(name: string, assetHash: string | null = null): AssetItem {
|
||||
@@ -150,7 +147,7 @@ function makeHistoryJob(
|
||||
filename: string,
|
||||
options: { id?: string; subfolder?: string } = {}
|
||||
): JobListItem {
|
||||
return fromPartial<JobListItem>({
|
||||
return fromAny<JobListItem, unknown>({
|
||||
id: options.id ?? filename,
|
||||
status: 'completed',
|
||||
create_time: 0,
|
||||
@@ -211,42 +208,6 @@ describe('scanNodeMediaCandidates', () => {
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty when a media node has no execution id', () => {
|
||||
const graph = makeGraph([])
|
||||
const node = makeMediaNode(
|
||||
1,
|
||||
'LoadImage',
|
||||
[makeMediaCombo('image', 'photo.png', [])],
|
||||
0,
|
||||
''
|
||||
)
|
||||
|
||||
const result = scanNodeMediaCandidates(graph, node, false)
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('ignores widgets that cannot provide a media filename', () => {
|
||||
const graph = makeGraph([])
|
||||
const nodeObj: unknown = {
|
||||
id: 1,
|
||||
type: 'LoadImage',
|
||||
widgets: [
|
||||
{ type: 'number', name: 'image', value: 'photo.png' },
|
||||
{ type: 'combo', name: 'other', value: 'photo.png' },
|
||||
{ type: 'combo', name: 'image', value: '' },
|
||||
{ type: 'combo', name: 'image', value: 42 }
|
||||
],
|
||||
mode: 0,
|
||||
_testExecutionId: '1'
|
||||
}
|
||||
const node = nodeObj as LGraphNode & { _testExecutionId?: string }
|
||||
|
||||
const result = scanNodeMediaCandidates(graph, node, false)
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it.for([false, true])(
|
||||
'returns empty while a media upload is pending on the node (isCloud: %s)',
|
||||
(isCloud) => {
|
||||
@@ -416,37 +377,6 @@ describe('scanNodeMediaCandidates', () => {
|
||||
})
|
||||
|
||||
describe('scanAllMediaCandidates', () => {
|
||||
it('returns empty when no root graph is available', () => {
|
||||
const nullGraph: unknown = null
|
||||
expect(scanAllMediaCandidates(nullGraph as LGraph, false)).toEqual([])
|
||||
})
|
||||
|
||||
it('skips nodes without widgets and subgraph nodes', () => {
|
||||
const withoutWidgetsObj: unknown = {
|
||||
id: 1,
|
||||
type: 'LoadImage',
|
||||
widgets: undefined,
|
||||
mode: 0
|
||||
}
|
||||
const withoutWidgets = withoutWidgetsObj as LGraphNode
|
||||
const subgraphNode = makeMediaNode(
|
||||
2,
|
||||
'LoadImage',
|
||||
[makeMediaCombo('image', 'photo.png', [])],
|
||||
0
|
||||
)
|
||||
const alwaysSubgraph: unknown = () => true
|
||||
subgraphNode.isSubgraphNode =
|
||||
alwaysSubgraph as typeof subgraphNode.isSubgraphNode
|
||||
|
||||
const result = scanAllMediaCandidates(
|
||||
makeGraph([withoutWidgets, subgraphNode]),
|
||||
false
|
||||
)
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('skips muted nodes (mode === NEVER)', () => {
|
||||
const node = makeMediaNode(
|
||||
1,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
@@ -79,14 +80,13 @@ function createMockGraph(
|
||||
links: ReturnType<typeof createMockLink>[] = []
|
||||
): LGraph {
|
||||
const linksMap = new Map(links.map((l) => [l.id, l]))
|
||||
const graph: unknown = {
|
||||
return fromAny<LGraph, unknown>({
|
||||
_nodes: nodes,
|
||||
_nodes_by_id: Object.fromEntries(nodes.map((n) => [n.id, n])),
|
||||
links: linksMap,
|
||||
updateExecutionOrder: vi.fn(),
|
||||
setDirtyCanvas: vi.fn()
|
||||
}
|
||||
return graph as LGraph
|
||||
})
|
||||
}
|
||||
|
||||
function createPlaceholderNode(
|
||||
@@ -96,7 +96,7 @@ function createPlaceholderNode(
|
||||
outputs: { name: string; links: number[] | null }[] = [],
|
||||
graph?: LGraph
|
||||
): LGraphNode {
|
||||
const node: unknown = {
|
||||
return fromAny<LGraphNode, unknown>({
|
||||
id,
|
||||
type,
|
||||
pos: [100, 200],
|
||||
@@ -132,8 +132,7 @@ function createPlaceholderNode(
|
||||
outputs: outputs.map((o) => ({ ...o, type: 'IMAGE' })),
|
||||
widgets_values: []
|
||||
}))
|
||||
}
|
||||
return node as LGraphNode
|
||||
})
|
||||
}
|
||||
|
||||
function createNewNode(
|
||||
@@ -141,7 +140,7 @@ function createNewNode(
|
||||
outputs: { name: string; links: number[] | null }[] = [],
|
||||
widgets: { name: string; value: unknown }[] = []
|
||||
): LGraphNode {
|
||||
const newNode: unknown = {
|
||||
return fromAny<LGraphNode, unknown>({
|
||||
id: 0,
|
||||
type: '',
|
||||
pos: [0, 0],
|
||||
@@ -155,8 +154,7 @@ function createNewNode(
|
||||
widgets: widgets.map((w) => ({ ...w, type: 'combo', options: {} })),
|
||||
configure: vi.fn(),
|
||||
serialize: vi.fn()
|
||||
}
|
||||
return newNode as LGraphNode
|
||||
})
|
||||
}
|
||||
|
||||
function makeMissingNodeType(
|
||||
@@ -408,31 +406,6 @@ describe('useNodeReplacement', () => {
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('skips strings and non-replaceable selected types while matching placeholders', () => {
|
||||
const placeholder = createPlaceholderNode(1, 'OldType')
|
||||
const graph = createMockGraph([placeholder])
|
||||
placeholder.graph = graph
|
||||
Object.assign(app, { rootGraph: graph })
|
||||
|
||||
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
|
||||
vi.mocked(LiteGraph.createNode).mockReturnValue(createNewNode())
|
||||
|
||||
const { replaceNodesInPlace } = useNodeReplacement()
|
||||
const result = replaceNodesInPlace([
|
||||
'OldType',
|
||||
{ type: 'OldType', isReplaceable: false },
|
||||
makeMissingNodeType('OldType', {
|
||||
new_node_id: 'NewType',
|
||||
old_node_id: 'OldType',
|
||||
old_widget_ids: null,
|
||||
input_mapping: null,
|
||||
output_mapping: null
|
||||
})
|
||||
])
|
||||
|
||||
expect(result).toEqual(['OldType'])
|
||||
})
|
||||
|
||||
it('should replace multiple different node types at once', () => {
|
||||
const placeholder1 = createPlaceholderNode(1, 'Load3DAnimation')
|
||||
const placeholder2 = createPlaceholderNode(
|
||||
@@ -522,118 +495,6 @@ describe('useNodeReplacement', () => {
|
||||
expect(graph._nodes[0]).toBe(newNode)
|
||||
})
|
||||
|
||||
it('copies serialized title and search-replace properties', () => {
|
||||
const placeholder = createPlaceholderNode(7, 'OldType')
|
||||
const noFlags: unknown = undefined
|
||||
placeholder.flags = noFlags as LGraphNode['flags']
|
||||
placeholder.last_serialization!.title = 'Kept title'
|
||||
placeholder.last_serialization!.properties = {
|
||||
'Node name for S&R': 'OldType',
|
||||
untouched: true
|
||||
}
|
||||
const graph = createMockGraph([placeholder])
|
||||
placeholder.graph = graph
|
||||
Object.assign(app, { rootGraph: graph })
|
||||
|
||||
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
|
||||
const newNode = createNewNode()
|
||||
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
|
||||
|
||||
const { replaceNodesInPlace } = useNodeReplacement()
|
||||
replaceNodesInPlace([
|
||||
makeMissingNodeType('OldType', {
|
||||
new_node_id: 'NewType',
|
||||
old_node_id: 'OldType',
|
||||
old_widget_ids: null,
|
||||
input_mapping: null,
|
||||
output_mapping: null
|
||||
})
|
||||
])
|
||||
|
||||
expect(newNode.title).toBe('Kept title')
|
||||
expect(newNode.flags).toEqual({})
|
||||
expect(newNode.properties).toEqual({
|
||||
'Node name for S&R': 'NewType',
|
||||
untouched: true
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to serialize when replacing nodes without cached serialization', () => {
|
||||
const placeholder = createPlaceholderNode(
|
||||
1,
|
||||
'OldType',
|
||||
[{ name: 'input', link: null }],
|
||||
[{ name: 'IMAGE', links: null }]
|
||||
)
|
||||
const noSerialization: unknown = undefined
|
||||
placeholder.last_serialization =
|
||||
noSerialization as LGraphNode['last_serialization']
|
||||
const graph = createMockGraph([placeholder])
|
||||
placeholder.graph = graph
|
||||
Object.assign(app, { rootGraph: graph })
|
||||
|
||||
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
|
||||
const newNode = createNewNode(
|
||||
[{ name: 'input', link: null }],
|
||||
[{ name: 'IMAGE', links: null }],
|
||||
[{ name: 'strength', value: 0 }]
|
||||
)
|
||||
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
|
||||
|
||||
const { replaceNodesInPlace } = useNodeReplacement()
|
||||
replaceNodesInPlace([
|
||||
makeMissingNodeType('OldType', {
|
||||
new_node_id: 'NewType',
|
||||
old_node_id: 'OldType',
|
||||
old_widget_ids: null,
|
||||
input_mapping: null,
|
||||
output_mapping: null
|
||||
})
|
||||
])
|
||||
|
||||
expect(placeholder.serialize).toHaveBeenCalled()
|
||||
expect(newNode.id).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps prior replacements when a later replacement throws', () => {
|
||||
const first = createPlaceholderNode(1, 'FirstType')
|
||||
const second = createPlaceholderNode(2, 'SecondType')
|
||||
const graph = createMockGraph([first, second])
|
||||
first.graph = graph
|
||||
second.graph = graph
|
||||
Object.assign(app, { rootGraph: graph })
|
||||
vi.mocked(collectAllNodes).mockReturnValue([first, second])
|
||||
vi.mocked(LiteGraph.createNode)
|
||||
.mockReturnValueOnce(createNewNode())
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error('create failed')
|
||||
})
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
const { replaceNodesInPlace } = useNodeReplacement()
|
||||
const result = replaceNodesInPlace([
|
||||
makeMissingNodeType('FirstType', {
|
||||
new_node_id: 'NewFirst',
|
||||
old_node_id: 'FirstType',
|
||||
old_widget_ids: null,
|
||||
input_mapping: null,
|
||||
output_mapping: null
|
||||
}),
|
||||
makeMissingNodeType('SecondType', {
|
||||
new_node_id: 'NewSecond',
|
||||
old_node_id: 'SecondType',
|
||||
old_widget_ids: null,
|
||||
input_mapping: null,
|
||||
output_mapping: null
|
||||
})
|
||||
])
|
||||
|
||||
expect(result).toEqual(['FirstType'])
|
||||
expect(graph.updateExecutionOrder).toHaveBeenCalled()
|
||||
expect(graph.setDirtyCanvas).toHaveBeenCalledWith(true, true)
|
||||
errorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should transfer all widget values for ImageScaleBy with real workflow data', () => {
|
||||
const placeholder = createPlaceholderNode(
|
||||
12,
|
||||
@@ -906,9 +767,10 @@ describe('useNodeReplacement', () => {
|
||||
|
||||
it('should exclude nodes without last_serialization', () => {
|
||||
const freshNode = createPlaceholderNode(1, 'OldNode')
|
||||
const noSerialization: unknown = undefined
|
||||
freshNode.last_serialization =
|
||||
noSerialization as LGraphNode['last_serialization']
|
||||
freshNode.last_serialization = fromAny<
|
||||
LGraphNode['last_serialization'],
|
||||
unknown
|
||||
>(undefined)
|
||||
const graph = createMockGraph([freshNode])
|
||||
Object.assign(app, { rootGraph: graph })
|
||||
|
||||
@@ -931,8 +793,7 @@ describe('useNodeReplacement', () => {
|
||||
|
||||
it('should fall back to node.type when last_serialization.type is undefined', () => {
|
||||
const node = createPlaceholderNode(1, 'FallbackType')
|
||||
const noSerializationType: unknown = undefined
|
||||
node.last_serialization!.type = noSerializationType as string
|
||||
node.last_serialization!.type = fromAny<string, unknown>(undefined)
|
||||
node.type = 'FallbackType'
|
||||
const graph = createMockGraph([node])
|
||||
Object.assign(app, { rootGraph: graph })
|
||||
@@ -961,8 +822,7 @@ describe('useNodeReplacement', () => {
|
||||
// targetTypes still holds the original unsanitized name "OldNode&Special",
|
||||
// so the predicate must fall back to checking sanitizeNodeName(originalType).
|
||||
const node = createPlaceholderNode(1, 'OldNodeSpecial')
|
||||
const noSerializationType: unknown = undefined
|
||||
node.last_serialization!.type = noSerializationType as string
|
||||
node.last_serialization!.type = fromAny<string, unknown>(undefined)
|
||||
// Simulate what sanitizeNodeName does to '&' in the live type
|
||||
node.type = 'OldNodeSpecial' // '&' already stripped by sanitizeNodeName
|
||||
const graph = createMockGraph([node])
|
||||
@@ -1127,30 +987,6 @@ describe('useNodeReplacement', () => {
|
||||
// Only TypeA was replaced; TypeB had no matching placeholder
|
||||
expect(mockRemoveMissingNodesByType).toHaveBeenCalledWith(['TypeA'])
|
||||
})
|
||||
|
||||
it('does not remove missing-node errors when no group nodes are replaced', () => {
|
||||
const graph = createMockGraph([])
|
||||
Object.assign(app, { rootGraph: graph })
|
||||
vi.mocked(collectAllNodes).mockReturnValue([])
|
||||
|
||||
const { replaceAllGroups } = useNodeReplacement()
|
||||
replaceAllGroups([
|
||||
{
|
||||
type: 'TypeA',
|
||||
nodeTypes: [
|
||||
makeMissingNodeType('TypeA', {
|
||||
new_node_id: 'NewA',
|
||||
old_node_id: 'TypeA',
|
||||
old_widget_ids: null,
|
||||
input_mapping: null,
|
||||
output_mapping: null
|
||||
})
|
||||
]
|
||||
}
|
||||
])
|
||||
|
||||
expect(mockRemoveMissingNodesByType).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('transfer edge cases', () => {
|
||||
@@ -1186,220 +1022,6 @@ describe('useNodeReplacement', () => {
|
||||
expect(newNode.inputs[0].link).toBeNull()
|
||||
})
|
||||
|
||||
it('skips input transfer when the new node has no matching input slot', () => {
|
||||
const link = createMockLink(10, 5, 0, 1, 0)
|
||||
const placeholder = createPlaceholderNode(1, 'OldType', [
|
||||
{ name: 'image', link: 10 }
|
||||
])
|
||||
const graph = createMockGraph([placeholder], [link])
|
||||
placeholder.graph = graph
|
||||
Object.assign(app, { rootGraph: graph })
|
||||
|
||||
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
|
||||
const newNode = createNewNode([{ name: 'other', link: null }], [])
|
||||
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
|
||||
|
||||
const { replaceNodesInPlace } = useNodeReplacement()
|
||||
replaceNodesInPlace([
|
||||
makeMissingNodeType('OldType', {
|
||||
new_node_id: 'NewType',
|
||||
old_node_id: 'OldType',
|
||||
old_widget_ids: null,
|
||||
input_mapping: [{ new_id: 'image', old_id: 'image' }],
|
||||
output_mapping: null
|
||||
})
|
||||
])
|
||||
|
||||
expect(link.target_id).toBe(1)
|
||||
expect(newNode.inputs[0].link).toBeNull()
|
||||
})
|
||||
|
||||
it('skips input transfer when the old link is missing or stale', () => {
|
||||
const placeholder = createPlaceholderNode(1, 'OldType', [
|
||||
{ name: 'null_link', link: null },
|
||||
{ name: 'stale_link', link: 99 }
|
||||
])
|
||||
const graph = createMockGraph([placeholder])
|
||||
placeholder.graph = graph
|
||||
Object.assign(app, { rootGraph: graph })
|
||||
|
||||
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
|
||||
const newNode = createNewNode(
|
||||
[
|
||||
{ name: 'null_link', link: null },
|
||||
{ name: 'stale_link', link: null }
|
||||
],
|
||||
[]
|
||||
)
|
||||
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
|
||||
|
||||
const { replaceNodesInPlace } = useNodeReplacement()
|
||||
replaceNodesInPlace([
|
||||
makeMissingNodeType('OldType', {
|
||||
new_node_id: 'NewType',
|
||||
old_node_id: 'OldType',
|
||||
old_widget_ids: null,
|
||||
input_mapping: [
|
||||
{ new_id: 'null_link', old_id: 'null_link' },
|
||||
{ new_id: 'stale_link', old_id: 'stale_link' }
|
||||
],
|
||||
output_mapping: null
|
||||
})
|
||||
])
|
||||
|
||||
expect(newNode.inputs.map((input) => input.link)).toEqual([null, null])
|
||||
})
|
||||
|
||||
it('skips output transfers with empty or stale old links', () => {
|
||||
const placeholder = createPlaceholderNode(
|
||||
1,
|
||||
'OldType',
|
||||
[],
|
||||
[
|
||||
{ name: 'EMPTY', links: [] },
|
||||
{ name: 'STALE', links: [99] }
|
||||
]
|
||||
)
|
||||
const graph = createMockGraph([placeholder])
|
||||
placeholder.graph = graph
|
||||
Object.assign(app, { rootGraph: graph })
|
||||
|
||||
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
|
||||
const newNode = createNewNode(
|
||||
[],
|
||||
[
|
||||
{ name: 'EMPTY', links: null },
|
||||
{ name: 'STALE', links: null }
|
||||
]
|
||||
)
|
||||
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
|
||||
|
||||
const { replaceNodesInPlace } = useNodeReplacement()
|
||||
replaceNodesInPlace([
|
||||
makeMissingNodeType('OldType', {
|
||||
new_node_id: 'NewType',
|
||||
old_node_id: 'OldType',
|
||||
old_widget_ids: null,
|
||||
input_mapping: null,
|
||||
output_mapping: [
|
||||
{ old_idx: 0, new_idx: 0 },
|
||||
{ old_idx: 1, new_idx: 1 }
|
||||
]
|
||||
})
|
||||
])
|
||||
|
||||
expect(newNode.outputs[0].links).toBeNull()
|
||||
expect(newNode.outputs[1].links).toEqual([99])
|
||||
})
|
||||
|
||||
it('skips widget transfer when the serialized value cannot be matched', () => {
|
||||
const placeholder = createPlaceholderNode(1, 'OldType', [
|
||||
{ name: 'image', link: null }
|
||||
])
|
||||
placeholder.last_serialization!.widgets_values = [undefined]
|
||||
const graph = createMockGraph([placeholder])
|
||||
placeholder.graph = graph
|
||||
Object.assign(app, { rootGraph: graph })
|
||||
|
||||
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
|
||||
const callback = vi.fn()
|
||||
const newNode = createNewNode(
|
||||
[{ name: 'image', link: null }],
|
||||
[],
|
||||
[{ name: 'image', value: 'unchanged' }]
|
||||
)
|
||||
Object.assign(newNode.widgets![0], { callback })
|
||||
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
|
||||
|
||||
const { replaceNodesInPlace } = useNodeReplacement()
|
||||
replaceNodesInPlace([
|
||||
makeMissingNodeType('OldType', {
|
||||
new_node_id: 'NewType',
|
||||
old_node_id: 'OldType',
|
||||
old_widget_ids: ['image'],
|
||||
input_mapping: [{ new_id: 'image', old_id: 'image' }],
|
||||
output_mapping: null
|
||||
})
|
||||
])
|
||||
|
||||
expect(newNode.widgets![0].value).toBe('unchanged')
|
||||
expect(callback).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls widget callbacks when transferring widget and set values', () => {
|
||||
const placeholder = createPlaceholderNode(1, 'OldType', [
|
||||
{ name: 'strength', link: null }
|
||||
])
|
||||
placeholder.last_serialization!.widgets_values = [0.75]
|
||||
const graph = createMockGraph([placeholder])
|
||||
placeholder.graph = graph
|
||||
Object.assign(app, { rootGraph: graph })
|
||||
|
||||
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
|
||||
const strengthCallback = vi.fn()
|
||||
const modeCallback = vi.fn()
|
||||
const newNode = createNewNode(
|
||||
[{ name: 'strength', link: null }],
|
||||
[],
|
||||
[
|
||||
{ name: 'strength', value: 0 },
|
||||
{ name: 'mode', value: 'old' }
|
||||
]
|
||||
)
|
||||
Object.assign(newNode.widgets![0], { callback: strengthCallback })
|
||||
Object.assign(newNode.widgets![1], { callback: modeCallback })
|
||||
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
|
||||
|
||||
const { replaceNodesInPlace } = useNodeReplacement()
|
||||
replaceNodesInPlace([
|
||||
makeMissingNodeType('OldType', {
|
||||
new_node_id: 'NewType',
|
||||
old_node_id: 'OldType',
|
||||
old_widget_ids: ['strength'],
|
||||
input_mapping: [
|
||||
{ new_id: 'strength', old_id: 'strength' },
|
||||
{ new_id: 'mode', set_value: 'new' }
|
||||
],
|
||||
output_mapping: null
|
||||
})
|
||||
])
|
||||
|
||||
expect(strengthCallback).toHaveBeenCalledWith(0.75)
|
||||
expect(modeCallback).toHaveBeenCalledWith('new')
|
||||
})
|
||||
|
||||
it('skips placeholders without a graph or graph index', () => {
|
||||
const noGraph = createPlaceholderNode(1, 'NoGraph')
|
||||
noGraph.graph = null
|
||||
const missingIndex = createPlaceholderNode(2, 'MissingIndex')
|
||||
const graph = createMockGraph([])
|
||||
missingIndex.graph = graph
|
||||
Object.assign(app, { rootGraph: graph })
|
||||
vi.mocked(collectAllNodes).mockReturnValue([noGraph, missingIndex])
|
||||
vi.mocked(LiteGraph.createNode).mockReturnValue(createNewNode())
|
||||
|
||||
const { replaceNodesInPlace } = useNodeReplacement()
|
||||
const result = replaceNodesInPlace([
|
||||
makeMissingNodeType('NoGraph', {
|
||||
new_node_id: 'NewNoGraph',
|
||||
old_node_id: 'NoGraph',
|
||||
old_widget_ids: null,
|
||||
input_mapping: null,
|
||||
output_mapping: null
|
||||
}),
|
||||
makeMissingNodeType('MissingIndex', {
|
||||
new_node_id: 'NewMissingIndex',
|
||||
old_node_id: 'MissingIndex',
|
||||
old_widget_ids: null,
|
||||
input_mapping: null,
|
||||
output_mapping: null
|
||||
})
|
||||
])
|
||||
|
||||
expect(result).toEqual([])
|
||||
expect(LiteGraph.createNode).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not throw when output_mapping references a new output index that does not exist', () => {
|
||||
// NOTE: The current source skips transfer silently in this case, leaving
|
||||
// the link's origin_slot pointing at a now-missing slot on the new node.
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { AxiosError, AxiosResponse } from 'axios'
|
||||
import type { AxiosError } from 'axios'
|
||||
import axios from 'axios'
|
||||
import { ref, watch } from 'vue'
|
||||
import { watch } from 'vue'
|
||||
|
||||
import { useApiRequest } from '@/composables/useApiRequest'
|
||||
import { getComfyApiBaseUrl } from '@/config/comfyApi'
|
||||
import type { components, operations } from '@/types/comfyRegistryTypes'
|
||||
import { isAbortError } from '@/utils/typeGuardUtil'
|
||||
|
||||
// Use generated types from OpenAPI spec
|
||||
export type ReleaseNote = components['schemas']['ReleaseNote']
|
||||
@@ -22,9 +22,6 @@ const releaseApiClient = axios.create({
|
||||
|
||||
// Release service for fetching release notes
|
||||
export const useReleaseService = () => {
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
watch(
|
||||
() => getComfyApiBaseUrl(),
|
||||
(url) => {
|
||||
@@ -32,10 +29,7 @@ export const useReleaseService = () => {
|
||||
}
|
||||
)
|
||||
|
||||
// No transformation needed - API response matches the generated type
|
||||
|
||||
// Handle API errors with context
|
||||
const handleApiError = (
|
||||
const mapError = (
|
||||
err: unknown,
|
||||
context: string,
|
||||
routeSpecificErrors?: Record<number, string>
|
||||
@@ -72,28 +66,10 @@ export const useReleaseService = () => {
|
||||
return `${context}: ${axiosError.message}`
|
||||
}
|
||||
|
||||
// Execute API request with error handling
|
||||
const executeApiRequest = async <T>(
|
||||
apiCall: () => Promise<AxiosResponse<T>>,
|
||||
errorContext: string,
|
||||
routeSpecificErrors?: Record<number, string>
|
||||
): Promise<T | null> => {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const response = await apiCall()
|
||||
return response.data
|
||||
} catch (err) {
|
||||
// Don't treat cancellations as errors
|
||||
if (isAbortError(err)) return null
|
||||
|
||||
error.value = handleApiError(err, errorContext, routeSpecificErrors)
|
||||
return null
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
const { isLoading, error, executeRequest } = useApiRequest({
|
||||
client: releaseApiClient,
|
||||
mapError
|
||||
})
|
||||
|
||||
// Fetch release notes from API
|
||||
const getReleases = async (
|
||||
@@ -107,17 +83,16 @@ export const useReleaseService = () => {
|
||||
400: 'Invalid project or version parameter'
|
||||
}
|
||||
|
||||
const apiResponse = await executeApiRequest(
|
||||
() =>
|
||||
releaseApiClient.get<ReleaseNote[]>(endpoint, {
|
||||
const apiResponse = await executeRequest(
|
||||
(client) =>
|
||||
client.get<ReleaseNote[]>(endpoint, {
|
||||
params,
|
||||
signal,
|
||||
headers: deployEnvironment
|
||||
? { 'Comfy-Env': deployEnvironment }
|
||||
: undefined
|
||||
}),
|
||||
errorContext,
|
||||
routeSpecificErrors
|
||||
{ errorContext, routeSpecificErrors }
|
||||
)
|
||||
|
||||
return apiResponse
|
||||
|
||||
198
src/services/comfyRegistryService.test.ts
Normal file
198
src/services/comfyRegistryService.test.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import axios from 'axios'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useComfyRegistryService } from '@/services/comfyRegistryService'
|
||||
|
||||
const mockAxiosInstance = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
post: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('axios', () => ({
|
||||
default: {
|
||||
create: vi.fn(() => mockAxiosInstance),
|
||||
isAxiosError: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
describe('useComfyRegistryService', () => {
|
||||
let service: ReturnType<typeof useComfyRegistryService>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockAxiosInstance.get.mockResolvedValue({ data: {} })
|
||||
mockAxiosInstance.post.mockResolvedValue({ data: {} })
|
||||
service = useComfyRegistryService()
|
||||
})
|
||||
|
||||
it('initializes with idle state', () => {
|
||||
expect(service.isLoading.value).toBe(false)
|
||||
expect(service.error.value).toBeNull()
|
||||
})
|
||||
|
||||
describe('request routing', () => {
|
||||
it('getNodeDefs hits the comfy-nodes endpoint', async () => {
|
||||
await service.getNodeDefs({ packId: 'pack', version: '1.0.0' })
|
||||
|
||||
expect(mockAxiosInstance.get).toHaveBeenCalledWith(
|
||||
'/nodes/pack/versions/1.0.0/comfy-nodes',
|
||||
expect.objectContaining({ params: {} })
|
||||
)
|
||||
})
|
||||
|
||||
it('getNodeDefs returns null without a packId or version', async () => {
|
||||
const result = await service.getNodeDefs({ packId: '', version: '' })
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(mockAxiosInstance.get).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('search hits the search endpoint', async () => {
|
||||
await service.search({ search: 'sampler' })
|
||||
|
||||
expect(mockAxiosInstance.get).toHaveBeenCalledWith(
|
||||
'/nodes/search',
|
||||
expect.objectContaining({ params: { search: 'sampler' } })
|
||||
)
|
||||
})
|
||||
|
||||
it('getPublisherById hits the publisher endpoint', async () => {
|
||||
await service.getPublisherById('pub-1')
|
||||
|
||||
expect(mockAxiosInstance.get).toHaveBeenCalledWith(
|
||||
'/publishers/pub-1',
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
|
||||
it('listPacksForPublisher forwards include_banned', async () => {
|
||||
await service.listPacksForPublisher('pub-1', true)
|
||||
|
||||
expect(mockAxiosInstance.get).toHaveBeenCalledWith(
|
||||
'/publishers/pub-1/nodes',
|
||||
expect.objectContaining({ params: { include_banned: true } })
|
||||
)
|
||||
})
|
||||
|
||||
it('postPackReview posts the star rating', async () => {
|
||||
await service.postPackReview('pack', 5)
|
||||
|
||||
expect(mockAxiosInstance.post).toHaveBeenCalledWith(
|
||||
'/nodes/pack/reviews',
|
||||
null,
|
||||
expect.objectContaining({ params: { star: 5 } })
|
||||
)
|
||||
})
|
||||
|
||||
it('listAllPacks hits the nodes endpoint', async () => {
|
||||
await service.listAllPacks({ page: 1 })
|
||||
|
||||
expect(mockAxiosInstance.get).toHaveBeenCalledWith(
|
||||
'/nodes',
|
||||
expect.objectContaining({ params: { page: 1 } })
|
||||
)
|
||||
})
|
||||
|
||||
it('getPackVersions hits the versions endpoint', async () => {
|
||||
await service.getPackVersions('pack')
|
||||
|
||||
expect(mockAxiosInstance.get).toHaveBeenCalledWith(
|
||||
'/nodes/pack/versions',
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
|
||||
it('getPackByVersion hits the specific version endpoint', async () => {
|
||||
await service.getPackByVersion('pack', 'v-1')
|
||||
|
||||
expect(mockAxiosInstance.get).toHaveBeenCalledWith(
|
||||
'/nodes/pack/versions/v-1',
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
|
||||
it('getPackById hits the node endpoint', async () => {
|
||||
await service.getPackById('pack')
|
||||
|
||||
expect(mockAxiosInstance.get).toHaveBeenCalledWith(
|
||||
'/nodes/pack',
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
|
||||
it('inferPackFromNodeName hits the comfy-nodes lookup endpoint', async () => {
|
||||
await service.inferPackFromNodeName('KSampler')
|
||||
|
||||
expect(mockAxiosInstance.get).toHaveBeenCalledWith(
|
||||
'/comfy-nodes/KSampler/node',
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
|
||||
it('getBulkNodeVersions posts the identifiers', async () => {
|
||||
const nodeVersions = [{ node_id: 'pack', version: '1.0.0' }]
|
||||
await service.getBulkNodeVersions(nodeVersions)
|
||||
|
||||
expect(mockAxiosInstance.post).toHaveBeenCalledWith(
|
||||
'/bulk/nodes/versions',
|
||||
{ node_versions: nodeVersions },
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
|
||||
it('returns the response data on success', async () => {
|
||||
mockAxiosInstance.get.mockResolvedValue({ data: { id: 'pack' } })
|
||||
|
||||
const result = await service.getPackById('pack')
|
||||
|
||||
expect(result).toEqual({ id: 'pack' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('error mapping', () => {
|
||||
it('prefers a route-specific message for a matching status', async () => {
|
||||
mockAxiosInstance.get.mockRejectedValue({
|
||||
response: { status: 404, data: {} }
|
||||
})
|
||||
vi.mocked(axios.isAxiosError).mockReturnValue(true)
|
||||
|
||||
const result = await service.getPackById('missing')
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(service.error.value).toBe(
|
||||
'Pack not found: The pack with ID missing does not exist'
|
||||
)
|
||||
})
|
||||
|
||||
it('maps generic status codes to friendly messages', async () => {
|
||||
mockAxiosInstance.get.mockRejectedValue({
|
||||
response: { status: 401, data: {} }
|
||||
})
|
||||
vi.mocked(axios.isAxiosError).mockReturnValue(true)
|
||||
|
||||
await service.search()
|
||||
|
||||
expect(service.error.value).toBe('Unauthorized: Authentication required')
|
||||
})
|
||||
|
||||
it('falls back to the axios message when there is no response', async () => {
|
||||
mockAxiosInstance.get.mockRejectedValue({ message: 'Network Error' })
|
||||
vi.mocked(axios.isAxiosError).mockReturnValue(true)
|
||||
|
||||
await service.search()
|
||||
|
||||
expect(service.error.value).toBe(
|
||||
'Failed to perform search: Network Error'
|
||||
)
|
||||
})
|
||||
|
||||
it('handles non-axios errors', async () => {
|
||||
mockAxiosInstance.get.mockRejectedValue(new Error('boom'))
|
||||
vi.mocked(axios.isAxiosError).mockReturnValue(false)
|
||||
|
||||
await service.search()
|
||||
|
||||
expect(service.error.value).toBe('Failed to perform search: boom')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { AxiosError, AxiosResponse } from 'axios'
|
||||
import type { AxiosError } from 'axios'
|
||||
import axios from 'axios'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useApiRequest } from '@/composables/useApiRequest'
|
||||
import type { components, operations } from '@/types/comfyRegistryTypes'
|
||||
import { isAbortError } from '@/utils/typeGuardUtil'
|
||||
|
||||
const API_BASE_URL = 'https://api.comfy.org'
|
||||
|
||||
@@ -22,10 +21,7 @@ const registryApiClient = axios.create({
|
||||
* Service for interacting with the Comfy Registry API
|
||||
*/
|
||||
export const useComfyRegistryService = () => {
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const handleApiError = (
|
||||
const mapError = (
|
||||
err: unknown,
|
||||
context: string,
|
||||
routeSpecificErrors?: Record<number, string>
|
||||
@@ -64,34 +60,10 @@ export const useComfyRegistryService = () => {
|
||||
return `${context}: ${axiosError.message}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an API request with error and loading state handling
|
||||
* @param apiCall - Function that returns a promise with the API call
|
||||
* @param errorContext - Context description for error messages
|
||||
* @param routeSpecificErrors - Optional map of status codes to custom error messages
|
||||
* @returns Promise with the API response data or null if the request failed
|
||||
*/
|
||||
const executeApiRequest = async <T>(
|
||||
apiCall: () => Promise<AxiosResponse<T>>,
|
||||
errorContext: string,
|
||||
routeSpecificErrors?: Record<number, string>
|
||||
): Promise<T | null> => {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const response = await apiCall()
|
||||
return response.data
|
||||
} catch (err) {
|
||||
// Don't treat cancellations as errors
|
||||
if (isAbortError(err)) return null
|
||||
|
||||
error.value = handleApiError(err, errorContext, routeSpecificErrors)
|
||||
return null
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
const { isLoading, error, executeRequest } = useApiRequest({
|
||||
client: registryApiClient,
|
||||
mapError
|
||||
})
|
||||
|
||||
/**
|
||||
* Get the Comfy Node definitions in a specific version of a node pack
|
||||
@@ -116,16 +88,15 @@ export const useComfyRegistryService = () => {
|
||||
404: 'The requested node, version, or comfy node does not exist'
|
||||
}
|
||||
|
||||
return executeApiRequest(
|
||||
() =>
|
||||
registryApiClient.get<
|
||||
return executeRequest(
|
||||
(client) =>
|
||||
client.get<
|
||||
operations['ListComfyNodes']['responses'][200]['content']['application/json']
|
||||
>(endpoint, {
|
||||
params: queryParams,
|
||||
signal
|
||||
}),
|
||||
errorContext,
|
||||
routeSpecificErrors
|
||||
{ errorContext, routeSpecificErrors }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -140,12 +111,12 @@ export const useComfyRegistryService = () => {
|
||||
const endpoint = '/nodes/search'
|
||||
const errorContext = 'Failed to perform search'
|
||||
|
||||
return executeApiRequest(
|
||||
() =>
|
||||
registryApiClient.get<
|
||||
return executeRequest(
|
||||
(client) =>
|
||||
client.get<
|
||||
operations['searchNodes']['responses'][200]['content']['application/json']
|
||||
>(endpoint, { params, signal }),
|
||||
errorContext
|
||||
{ errorContext }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -162,13 +133,12 @@ export const useComfyRegistryService = () => {
|
||||
404: `Publisher not found: The publisher with ID ${publisherId} does not exist`
|
||||
}
|
||||
|
||||
return executeApiRequest(
|
||||
() =>
|
||||
registryApiClient.get<components['schemas']['Publisher']>(endpoint, {
|
||||
return executeRequest(
|
||||
(client) =>
|
||||
client.get<components['schemas']['Publisher']>(endpoint, {
|
||||
signal
|
||||
}),
|
||||
errorContext,
|
||||
routeSpecificErrors
|
||||
{ errorContext, routeSpecificErrors }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -188,14 +158,13 @@ export const useComfyRegistryService = () => {
|
||||
404: `Publisher not found: The publisher with ID ${publisherId} does not exist`
|
||||
}
|
||||
|
||||
return executeApiRequest(
|
||||
() =>
|
||||
registryApiClient.get<components['schemas']['Node'][]>(endpoint, {
|
||||
return executeRequest(
|
||||
(client) =>
|
||||
client.get<components['schemas']['Node'][]>(endpoint, {
|
||||
params,
|
||||
signal
|
||||
}),
|
||||
errorContext,
|
||||
routeSpecificErrors
|
||||
{ errorContext, routeSpecificErrors }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -215,14 +184,13 @@ export const useComfyRegistryService = () => {
|
||||
404: `Pack not found: Pack with ID ${packId} does not exist`
|
||||
}
|
||||
|
||||
return executeApiRequest(
|
||||
() =>
|
||||
registryApiClient.post<components['schemas']['Node']>(endpoint, null, {
|
||||
return executeRequest(
|
||||
(client) =>
|
||||
client.post<components['schemas']['Node']>(endpoint, null, {
|
||||
params,
|
||||
signal
|
||||
}),
|
||||
errorContext,
|
||||
routeSpecificErrors
|
||||
{ errorContext, routeSpecificErrors }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -236,12 +204,12 @@ export const useComfyRegistryService = () => {
|
||||
const endpoint = '/nodes'
|
||||
const errorContext = 'Failed to list packs'
|
||||
|
||||
return executeApiRequest(
|
||||
() =>
|
||||
registryApiClient.get<
|
||||
return executeRequest(
|
||||
(client) =>
|
||||
client.get<
|
||||
operations['listAllNodes']['responses'][200]['content']['application/json']
|
||||
>(endpoint, { params, signal }),
|
||||
errorContext
|
||||
{ errorContext }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -260,14 +228,13 @@ export const useComfyRegistryService = () => {
|
||||
404: `Pack not found: Pack with ID ${packId} does not exist`
|
||||
}
|
||||
|
||||
return executeApiRequest(
|
||||
() =>
|
||||
registryApiClient.get<components['schemas']['NodeVersion'][]>(
|
||||
endpoint,
|
||||
{ params, signal }
|
||||
),
|
||||
errorContext,
|
||||
routeSpecificErrors
|
||||
return executeRequest(
|
||||
(client) =>
|
||||
client.get<components['schemas']['NodeVersion'][]>(endpoint, {
|
||||
params,
|
||||
signal
|
||||
}),
|
||||
{ errorContext, routeSpecificErrors }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -286,13 +253,12 @@ export const useComfyRegistryService = () => {
|
||||
404: `Pack not found: Pack with ID ${packId} does not exist`
|
||||
}
|
||||
|
||||
return executeApiRequest(
|
||||
() =>
|
||||
registryApiClient.get<components['schemas']['NodeVersion']>(endpoint, {
|
||||
return executeRequest(
|
||||
(client) =>
|
||||
client.get<components['schemas']['NodeVersion']>(endpoint, {
|
||||
signal
|
||||
}),
|
||||
errorContext,
|
||||
routeSpecificErrors
|
||||
{ errorContext, routeSpecificErrors }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -309,13 +275,12 @@ export const useComfyRegistryService = () => {
|
||||
404: `Pack not found: The pack with ID ${packId} does not exist`
|
||||
}
|
||||
|
||||
return executeApiRequest(
|
||||
() =>
|
||||
registryApiClient.get<components['schemas']['Node']>(endpoint, {
|
||||
return executeRequest(
|
||||
(client) =>
|
||||
client.get<components['schemas']['Node']>(endpoint, {
|
||||
signal
|
||||
}),
|
||||
errorContext,
|
||||
routeSpecificErrors
|
||||
{ errorContext, routeSpecificErrors }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -350,13 +315,12 @@ export const useComfyRegistryService = () => {
|
||||
404: `Comfy node not found: The node with name ${nodeName} does not exist in the registry`
|
||||
}
|
||||
|
||||
return executeApiRequest(
|
||||
() =>
|
||||
registryApiClient.get<components['schemas']['Node']>(endpoint, {
|
||||
return executeRequest(
|
||||
(client) =>
|
||||
client.get<components['schemas']['Node']>(endpoint, {
|
||||
signal
|
||||
}),
|
||||
errorContext,
|
||||
routeSpecificErrors
|
||||
{ errorContext, routeSpecificErrors }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -397,15 +361,16 @@ export const useComfyRegistryService = () => {
|
||||
node_versions: nodeVersions
|
||||
}
|
||||
|
||||
return executeApiRequest(
|
||||
() =>
|
||||
registryApiClient.post<
|
||||
components['schemas']['BulkNodeVersionsResponse']
|
||||
>(endpoint, requestBody, {
|
||||
signal
|
||||
}),
|
||||
errorContext,
|
||||
routeSpecificErrors
|
||||
return executeRequest(
|
||||
(client) =>
|
||||
client.post<components['schemas']['BulkNodeVersionsResponse']>(
|
||||
endpoint,
|
||||
requestBody,
|
||||
{
|
||||
signal
|
||||
}
|
||||
),
|
||||
{ errorContext, routeSpecificErrors }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { AxiosError, AxiosResponse } from 'axios'
|
||||
import type { AxiosError } from 'axios'
|
||||
import axios from 'axios'
|
||||
import { ref, watch } from 'vue'
|
||||
import { watch } from 'vue'
|
||||
|
||||
import { useApiRequest } from '@/composables/useApiRequest'
|
||||
import { attachUnifiedRemintInterceptor } from '@/platform/auth/unified/remintRetry'
|
||||
import { getComfyApiBaseUrl } from '@/config/comfyApi'
|
||||
import { d, t } from '@/i18n'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import type { components, operations } from '@/types/comfyRegistryTypes'
|
||||
import { isAbortError } from '@/utils/typeGuardUtil'
|
||||
|
||||
export enum EventType {
|
||||
CREDIT_ADDED = 'credit_added',
|
||||
@@ -34,9 +34,6 @@ const customerApiClient = axios.create({
|
||||
attachUnifiedRemintInterceptor(customerApiClient)
|
||||
|
||||
export const useCustomerEventsService = () => {
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
watch(
|
||||
() => getComfyApiBaseUrl(),
|
||||
(url) => {
|
||||
@@ -44,54 +41,31 @@ export const useCustomerEventsService = () => {
|
||||
}
|
||||
)
|
||||
|
||||
const handleRequestError = (
|
||||
const mapError = (
|
||||
err: unknown,
|
||||
context: string,
|
||||
routeSpecificErrors?: Record<number, string>
|
||||
) => {
|
||||
// Don't treat cancellation as an error
|
||||
if (isAbortError(err)) return
|
||||
|
||||
let message: string
|
||||
): string => {
|
||||
if (!axios.isAxiosError(err)) {
|
||||
message = `${context} failed: ${err instanceof Error ? err.message : String(err)}`
|
||||
} else {
|
||||
const axiosError = err as AxiosError<{ message: string }>
|
||||
const status = axiosError.response?.status
|
||||
if (status && routeSpecificErrors?.[status]) {
|
||||
message = routeSpecificErrors[status]
|
||||
} else {
|
||||
message =
|
||||
axiosError.response?.data?.message ??
|
||||
`${context} failed with status ${status}`
|
||||
}
|
||||
return `${context} failed: ${err instanceof Error ? err.message : String(err)}`
|
||||
}
|
||||
|
||||
error.value = message
|
||||
const axiosError = err as AxiosError<{ message: string }>
|
||||
const status = axiosError.response?.status
|
||||
if (status && routeSpecificErrors?.[status]) {
|
||||
return routeSpecificErrors[status]
|
||||
}
|
||||
|
||||
return (
|
||||
axiosError.response?.data?.message ??
|
||||
`${context} failed with status ${status}`
|
||||
)
|
||||
}
|
||||
|
||||
const executeRequest = async <T>(
|
||||
requestCall: () => Promise<AxiosResponse<T>>,
|
||||
options: {
|
||||
errorContext: string
|
||||
routeSpecificErrors?: Record<number, string>
|
||||
}
|
||||
): Promise<T | null> => {
|
||||
const { errorContext, routeSpecificErrors } = options
|
||||
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const response = await requestCall()
|
||||
return response.data
|
||||
} catch (err) {
|
||||
handleRequestError(err, errorContext, routeSpecificErrors)
|
||||
return null
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
const { isLoading, error, executeRequest } = useApiRequest({
|
||||
client: customerApiClient,
|
||||
mapError
|
||||
})
|
||||
|
||||
function formatEventType(eventType: string) {
|
||||
switch (eventType) {
|
||||
@@ -198,8 +172,8 @@ export const useCustomerEventsService = () => {
|
||||
}
|
||||
|
||||
const result = await executeRequest<CustomerEventsResponse>(
|
||||
() =>
|
||||
customerApiClient.get('/customers/events', {
|
||||
(client) =>
|
||||
client.get('/customers/events', {
|
||||
params: { page, limit },
|
||||
headers: authHeaders
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
import axios from 'axios'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useComfyManagerService } from '@/workbench/extensions/manager/services/comfyManagerService'
|
||||
|
||||
const mockAxiosInstance = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
post: vi.fn()
|
||||
}))
|
||||
|
||||
const managerState = vi.hoisted(() => ({ isNewManagerUI: true }))
|
||||
|
||||
vi.mock('axios', () => ({
|
||||
default: {
|
||||
create: vi.fn(() => mockAxiosInstance),
|
||||
isAxiosError: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
apiURL: (path: string) => path,
|
||||
clientId: 'client-1',
|
||||
initialClientId: null
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/workbench/extensions/manager/composables/useManagerState', () => ({
|
||||
useManagerState: () => ({
|
||||
isNewManagerUI: { value: managerState.isNewManagerUI }
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('uuid', () => ({ v4: () => 'generated-uuid' }))
|
||||
|
||||
describe('useComfyManagerService', () => {
|
||||
let service: ReturnType<typeof useComfyManagerService>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
managerState.isNewManagerUI = true
|
||||
mockAxiosInstance.get.mockResolvedValue({ data: {} })
|
||||
mockAxiosInstance.post.mockResolvedValue({ data: null })
|
||||
service = useComfyManagerService()
|
||||
})
|
||||
|
||||
it('initializes with idle state', () => {
|
||||
expect(service.isLoading.value).toBe(false)
|
||||
expect(service.error.value).toBeNull()
|
||||
})
|
||||
|
||||
describe('availability gate', () => {
|
||||
it('short-circuits requests when Manager is not in NEW_UI mode', async () => {
|
||||
managerState.isNewManagerUI = false
|
||||
|
||||
const result = await service.listInstalledPacks()
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(mockAxiosInstance.get).not.toHaveBeenCalled()
|
||||
expect(service.error.value).toBe(
|
||||
'Manager service is not available in current mode'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('read requests', () => {
|
||||
it('getQueueStatus forwards the client_id param', async () => {
|
||||
await service.getQueueStatus('abc')
|
||||
|
||||
expect(mockAxiosInstance.get).toHaveBeenCalledWith(
|
||||
'manager/queue/status',
|
||||
expect.objectContaining({ params: { client_id: 'abc' } })
|
||||
)
|
||||
})
|
||||
|
||||
it('listInstalledPacks hits the installed endpoint', async () => {
|
||||
await service.listInstalledPacks()
|
||||
|
||||
expect(mockAxiosInstance.get).toHaveBeenCalledWith(
|
||||
'customnode/installed',
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
|
||||
it('getImportFailInfo hits the import-fail endpoint', async () => {
|
||||
await service.getImportFailInfo()
|
||||
|
||||
expect(mockAxiosInstance.get).toHaveBeenCalledWith(
|
||||
'customnode/import_fail_info',
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
|
||||
it('getImportFailInfoBulk returns empty without identifiers', async () => {
|
||||
const result = await service.getImportFailInfoBulk({})
|
||||
|
||||
expect(result).toEqual({})
|
||||
expect(mockAxiosInstance.post).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('getImportFailInfoBulk posts when identifiers are present', async () => {
|
||||
await service.getImportFailInfoBulk({ cnr_ids: ['a'] })
|
||||
|
||||
expect(mockAxiosInstance.post).toHaveBeenCalledWith(
|
||||
'customnode/import_fail_info_bulk',
|
||||
{ cnr_ids: ['a'] },
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
|
||||
it('isLegacyManagerUI hits the legacy-ui endpoint', async () => {
|
||||
await service.isLegacyManagerUI()
|
||||
|
||||
expect(mockAxiosInstance.get).toHaveBeenCalledWith(
|
||||
'manager/is_legacy_manager_ui',
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
|
||||
it('getTaskHistory forwards options as params', async () => {
|
||||
await service.getTaskHistory({ max_items: 5 })
|
||||
|
||||
expect(mockAxiosInstance.get).toHaveBeenCalledWith(
|
||||
'manager/queue/history',
|
||||
expect.objectContaining({ params: { max_items: 5 } })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('queue operations', () => {
|
||||
it('installPack queues an install task then starts the queue', async () => {
|
||||
await service.installPack({
|
||||
id: 'pack',
|
||||
version: '1.0.0',
|
||||
selected_version: '1.0.0',
|
||||
mode: 'remote',
|
||||
channel: 'default'
|
||||
})
|
||||
|
||||
expect(mockAxiosInstance.post).toHaveBeenCalledWith(
|
||||
'manager/queue/task',
|
||||
expect.objectContaining({ kind: 'install' }),
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(mockAxiosInstance.post).toHaveBeenCalledWith(
|
||||
'manager/queue/start',
|
||||
null,
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
|
||||
it('uninstallPack queues an uninstall task', async () => {
|
||||
await service.uninstallPack({ node_name: 'pack', is_unknown: false })
|
||||
|
||||
expect(mockAxiosInstance.post).toHaveBeenCalledWith(
|
||||
'manager/queue/task',
|
||||
expect.objectContaining({ kind: 'uninstall' }),
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
|
||||
it('updateAllPacks posts to the update_all endpoint', async () => {
|
||||
await service.updateAllPacks({ mode: 'remote' })
|
||||
|
||||
expect(mockAxiosInstance.post).toHaveBeenCalledWith(
|
||||
'manager/queue/update_all',
|
||||
null,
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({ mode: 'remote' })
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('updateComfyUI posts to the update_comfyui endpoint', async () => {
|
||||
await service.updateComfyUI({ is_stable: true })
|
||||
|
||||
expect(mockAxiosInstance.post).toHaveBeenCalledWith(
|
||||
'manager/queue/update_comfyui',
|
||||
null,
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({ is_stable: true })
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('rebootComfyUI posts to the reboot endpoint', async () => {
|
||||
await service.rebootComfyUI()
|
||||
|
||||
expect(mockAxiosInstance.post).toHaveBeenCalledWith(
|
||||
'manager/reboot',
|
||||
null,
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
|
||||
it('startQueue posts to the start endpoint', async () => {
|
||||
await service.startQueue()
|
||||
|
||||
expect(mockAxiosInstance.post).toHaveBeenCalledWith(
|
||||
'manager/queue/start',
|
||||
null,
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('error mapping', () => {
|
||||
it('prefers a route-specific message for a matching status', async () => {
|
||||
mockAxiosInstance.post.mockRejectedValue({
|
||||
response: { status: 403, data: {} }
|
||||
})
|
||||
vi.mocked(axios.isAxiosError).mockReturnValue(true)
|
||||
|
||||
await service.rebootComfyUI()
|
||||
|
||||
expect(service.error.value).toBe(
|
||||
'Forbidden: Rebooting ComfyUI requires security_level of middle or below'
|
||||
)
|
||||
})
|
||||
|
||||
it('maps 404 to a connection message', async () => {
|
||||
mockAxiosInstance.get.mockRejectedValue({
|
||||
response: { status: 404, data: {} }
|
||||
})
|
||||
vi.mocked(axios.isAxiosError).mockReturnValue(true)
|
||||
|
||||
await service.listInstalledPacks()
|
||||
|
||||
expect(service.error.value).toBe('Could not connect to ComfyUI-Manager')
|
||||
})
|
||||
|
||||
it('falls back to the response message for other statuses', async () => {
|
||||
mockAxiosInstance.get.mockRejectedValue({
|
||||
response: { status: 500, data: { message: 'server exploded' } }
|
||||
})
|
||||
vi.mocked(axios.isAxiosError).mockReturnValue(true)
|
||||
|
||||
await service.listInstalledPacks()
|
||||
|
||||
expect(service.error.value).toBe('server exploded')
|
||||
})
|
||||
|
||||
it('handles non-axios errors', async () => {
|
||||
mockAxiosInstance.get.mockRejectedValue(new Error('boom'))
|
||||
vi.mocked(axios.isAxiosError).mockReturnValue(false)
|
||||
|
||||
await service.listInstalledPacks()
|
||||
|
||||
expect(service.error.value).toBe('Fetching installed packs failed: boom')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,9 @@
|
||||
import type { AxiosError, AxiosResponse } from 'axios'
|
||||
import type { AxiosError, AxiosInstance, AxiosResponse } from 'axios'
|
||||
import axios from 'axios'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useApiRequest } from '@/composables/useApiRequest'
|
||||
import { api } from '@/scripts/api'
|
||||
import { isAbortError } from '@/utils/typeGuardUtil'
|
||||
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
|
||||
import type { components } from '@/workbench/extensions/manager/types/generatedManagerTypes'
|
||||
|
||||
@@ -51,72 +50,64 @@ const managerApiClient = axios.create({
|
||||
* Note: This service should only be used when Manager state is NEW_UI
|
||||
*/
|
||||
export const useComfyManagerService = () => {
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
// Check if manager service should be available
|
||||
const isManagerServiceAvailable = () => {
|
||||
const managerState = useManagerState()
|
||||
return managerState.isNewManagerUI.value
|
||||
}
|
||||
|
||||
const handleRequestError = (
|
||||
const mapError = (
|
||||
err: unknown,
|
||||
context: string,
|
||||
routeSpecificErrors?: Record<number, string>
|
||||
) => {
|
||||
// Don't treat cancellation as an error
|
||||
if (isAbortError(err)) return
|
||||
|
||||
let message: string
|
||||
): string => {
|
||||
if (!axios.isAxiosError(err)) {
|
||||
message = `${context} failed: ${err instanceof Error ? err.message : String(err)}`
|
||||
} else {
|
||||
const axiosError = err as AxiosError<{ message: string }>
|
||||
const status = axiosError.response?.status
|
||||
if (status && routeSpecificErrors?.[status]) {
|
||||
message = routeSpecificErrors[status]
|
||||
} else if (status === 404) {
|
||||
message = 'Could not connect to ComfyUI-Manager'
|
||||
} else {
|
||||
message =
|
||||
axiosError.response?.data?.message ??
|
||||
`${context} failed with status ${status}`
|
||||
}
|
||||
return `${context} failed: ${err instanceof Error ? err.message : String(err)}`
|
||||
}
|
||||
|
||||
error.value = message
|
||||
const axiosError = err as AxiosError<{ message: string }>
|
||||
const status = axiosError.response?.status
|
||||
if (status && routeSpecificErrors?.[status]) {
|
||||
return routeSpecificErrors[status]
|
||||
}
|
||||
if (status === 404) {
|
||||
return 'Could not connect to ComfyUI-Manager'
|
||||
}
|
||||
|
||||
return (
|
||||
axiosError.response?.data?.message ??
|
||||
`${context} failed with status ${status}`
|
||||
)
|
||||
}
|
||||
|
||||
const executeRequest = async <T>(
|
||||
requestCall: () => Promise<AxiosResponse<T>>,
|
||||
const {
|
||||
isLoading,
|
||||
error,
|
||||
executeRequest: sendRequest
|
||||
} = useApiRequest({
|
||||
client: managerApiClient,
|
||||
mapError
|
||||
})
|
||||
|
||||
const executeRequest = <T>(
|
||||
apiCall: (client: AxiosInstance) => Promise<AxiosResponse<T>>,
|
||||
options: {
|
||||
errorContext: string
|
||||
routeSpecificErrors?: Record<number, string>
|
||||
isQueueOperation?: boolean
|
||||
}
|
||||
): Promise<T | null> => {
|
||||
const { errorContext, routeSpecificErrors, isQueueOperation } = options
|
||||
|
||||
// Block service calls if not in NEW_UI state
|
||||
if (!isManagerServiceAvailable()) {
|
||||
error.value = 'Manager service is not available in current mode'
|
||||
return null
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const response = await requestCall()
|
||||
if (isQueueOperation) await startQueue()
|
||||
return response.data
|
||||
} catch (err) {
|
||||
handleRequestError(err, errorContext, routeSpecificErrors)
|
||||
return null
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
const { isQueueOperation, ...requestOptions } = options
|
||||
return sendRequest(apiCall, {
|
||||
...requestOptions,
|
||||
onSuccess: isQueueOperation ? startQueue : undefined
|
||||
})
|
||||
}
|
||||
|
||||
const startQueue = async (signal?: AbortSignal) => {
|
||||
@@ -126,7 +117,7 @@ export const useComfyManagerService = () => {
|
||||
}
|
||||
|
||||
return executeRequest<null>(
|
||||
() => managerApiClient.post(ManagerRoute.START_QUEUE, null, { signal }),
|
||||
(client) => client.post(ManagerRoute.START_QUEUE, null, { signal }),
|
||||
{ errorContext, routeSpecificErrors }
|
||||
)
|
||||
}
|
||||
@@ -135,8 +126,8 @@ export const useComfyManagerService = () => {
|
||||
const errorContext = 'Getting ComfyUI-Manager queue status'
|
||||
|
||||
return executeRequest<ManagerQueueStatus>(
|
||||
() =>
|
||||
managerApiClient.get(ManagerRoute.QUEUE_STATUS, {
|
||||
(client) =>
|
||||
client.get(ManagerRoute.QUEUE_STATUS, {
|
||||
params: client_id ? { client_id } : undefined,
|
||||
signal
|
||||
}),
|
||||
@@ -148,7 +139,7 @@ export const useComfyManagerService = () => {
|
||||
const errorContext = 'Fetching installed packs'
|
||||
|
||||
return executeRequest<InstalledPacksResponse>(
|
||||
() => managerApiClient.get(ManagerRoute.LIST_INSTALLED, { signal }),
|
||||
(client) => client.get(ManagerRoute.LIST_INSTALLED, { signal }),
|
||||
{ errorContext }
|
||||
)
|
||||
}
|
||||
@@ -157,7 +148,7 @@ export const useComfyManagerService = () => {
|
||||
const errorContext = 'Fetching import failure information'
|
||||
|
||||
return executeRequest<Record<string, unknown>>(
|
||||
() => managerApiClient.get(ManagerRoute.IMPORT_FAIL_INFO, { signal }),
|
||||
(client) => client.get(ManagerRoute.IMPORT_FAIL_INFO, { signal }),
|
||||
{ errorContext }
|
||||
)
|
||||
}
|
||||
@@ -173,8 +164,8 @@ export const useComfyManagerService = () => {
|
||||
}
|
||||
|
||||
return executeRequest<components['schemas']['ImportFailInfoBulkResponse']>(
|
||||
() =>
|
||||
managerApiClient.post(ManagerRoute.IMPORT_FAIL_INFO_BULK, params, {
|
||||
(client) =>
|
||||
client.post(ManagerRoute.IMPORT_FAIL_INFO_BULK, params, {
|
||||
signal
|
||||
}),
|
||||
{ errorContext }
|
||||
@@ -201,7 +192,7 @@ export const useComfyManagerService = () => {
|
||||
}
|
||||
|
||||
return executeRequest<null>(
|
||||
() => managerApiClient.post(ManagerRoute.QUEUE_TASK, task, { signal }),
|
||||
(client) => client.post(ManagerRoute.QUEUE_TASK, task, { signal }),
|
||||
{ errorContext, routeSpecificErrors, isQueueOperation: true }
|
||||
)
|
||||
}
|
||||
@@ -264,8 +255,8 @@ export const useComfyManagerService = () => {
|
||||
}
|
||||
|
||||
return executeRequest<null>(
|
||||
() =>
|
||||
managerApiClient.post(ManagerRoute.UPDATE_ALL, null, {
|
||||
(client) =>
|
||||
client.post(ManagerRoute.UPDATE_ALL, null, {
|
||||
params: queryParams,
|
||||
signal
|
||||
}),
|
||||
@@ -291,8 +282,8 @@ export const useComfyManagerService = () => {
|
||||
}
|
||||
|
||||
return executeRequest<null>(
|
||||
() =>
|
||||
managerApiClient.post(ManagerRoute.UPDATE_COMFYUI, null, {
|
||||
(client) =>
|
||||
client.post(ManagerRoute.UPDATE_COMFYUI, null, {
|
||||
params: queryParams,
|
||||
signal
|
||||
}),
|
||||
@@ -307,7 +298,7 @@ export const useComfyManagerService = () => {
|
||||
}
|
||||
|
||||
return executeRequest<null>(
|
||||
() => managerApiClient.post(ManagerRoute.REBOOT, null, { signal }),
|
||||
(client) => client.post(ManagerRoute.REBOOT, null, { signal }),
|
||||
{ errorContext, routeSpecificErrors }
|
||||
)
|
||||
}
|
||||
@@ -316,7 +307,7 @@ export const useComfyManagerService = () => {
|
||||
const errorContext = 'Checking if user set Manager to use the legacy UI'
|
||||
|
||||
return executeRequest<{ is_legacy_manager_ui: boolean }>(
|
||||
() => managerApiClient.get(ManagerRoute.IS_LEGACY_MANAGER_UI, { signal }),
|
||||
(client) => client.get(ManagerRoute.IS_LEGACY_MANAGER_UI, { signal }),
|
||||
{ errorContext }
|
||||
)
|
||||
}
|
||||
@@ -333,8 +324,8 @@ export const useComfyManagerService = () => {
|
||||
const errorContext = 'Getting ComfyUI-Manager task history'
|
||||
|
||||
return executeRequest<ManagerTaskHistory>(
|
||||
() =>
|
||||
managerApiClient.get(ManagerRoute.TASK_HISTORY, {
|
||||
(client) =>
|
||||
client.get(ManagerRoute.TASK_HISTORY, {
|
||||
params: options,
|
||||
signal
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user