Compare commits
10 Commits
codex/cove
...
nathaniel/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5074c999d | ||
|
|
747f76db76 | ||
|
|
386460afef | ||
|
|
5cf647d183 | ||
|
|
fe1fc8baa6 | ||
|
|
3e4dd59e5f | ||
|
|
e25e0f2e16 | ||
|
|
2ee91c30ee | ||
|
|
854770d305 | ||
|
|
2e4c9c6fdc |
197
.github/workflows/backport-auto-merge.yaml
vendored
Normal file
@@ -0,0 +1,197 @@
|
||||
---
|
||||
name: Backport Auto-Merge
|
||||
|
||||
# Completes the merge of backport PRs once they are approved and their required
|
||||
# checks pass.
|
||||
#
|
||||
# Background: pr-backport.yaml opens each backport PR (labelled `backport`) and
|
||||
# calls `gh pr merge --auto`, which relies on the repo-level "Allow auto-merge"
|
||||
# setting. That setting is off, so `--auto` is a silent no-op and backport PRs
|
||||
# sit unmerged until a human clicks merge. This workflow performs the merge
|
||||
# directly (a plain `gh pr merge --squash`, which does not depend on that
|
||||
# setting) once GitHub itself reports the PR as ready to merge.
|
||||
#
|
||||
# Safety: branch protection on core/** and cloud/** is the hard gate — it
|
||||
# unconditionally requires an approval + the required status checks and cannot
|
||||
# be bypassed, and GitHub's merge API re-enforces it at merge time. This
|
||||
# workflow can only ever complete a merge that already satisfies those rules;
|
||||
# the eligibility check below only avoids pointless merge attempts.
|
||||
#
|
||||
# The merge uses PR_GH_TOKEN (not the default GITHUB_TOKEN) on purpose: a merge
|
||||
# performed by the default token does not emit events that trigger other
|
||||
# workflows, which would silently starve cloud-backport-tag.yaml (it runs on the
|
||||
# backport PR's `pull_request: closed` event to create the release tag).
|
||||
|
||||
on:
|
||||
# Fires when someone approves — if the required checks are already green, the
|
||||
# PR merges immediately.
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
# Primary catch for the "approved first, checks went green later" case, plus a
|
||||
# general backstop. A `check_suite`/`workflow_run` trigger would react faster to
|
||||
# checks completing, but GitHub suppresses `check_suite` events for its own
|
||||
# Actions suites (so it wouldn't fire for this repo's CI), and `workflow_run` is
|
||||
# a secrets-bearing "dangerous" trigger we don't want on a public repo for a
|
||||
# non-latency-critical task. Backports wait hours today, so a short sweep is a
|
||||
# large improvement and needs neither.
|
||||
schedule:
|
||||
- cron: '*/15 * * * *'
|
||||
|
||||
# Only constrains the default github.token (used for read-only PR lookups below).
|
||||
# It does NOT constrain PR_GH_TOKEN, whose authority is fixed by its own scopes.
|
||||
permissions:
|
||||
contents: read # read-only; required for gh api / gh pr list to resolve candidates
|
||||
pull-requests: read # read-only; required for gh pr view eligibility checks
|
||||
|
||||
# Serialize runs that act on the same PR (review events keyed by PR number; all
|
||||
# scheduled sweeps share one key). Cross-key overlaps are still possible but
|
||||
# harmless: the merge loop treats an already-merged PR as success (idempotent).
|
||||
concurrency:
|
||||
group: backport-auto-merge-${{ github.event.pull_request.number || 'sweep' }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
merge:
|
||||
name: Merge eligible backport PRs
|
||||
# Skip review events that can't possibly make a PR mergeable — non-approval
|
||||
# reviews, or reviews on non-backport PRs (most reviews in the repo) — before
|
||||
# spending any API call. Schedule sweeps always proceed. The per-PR
|
||||
# eligibility checks in the job still re-verify the label and decision from
|
||||
# live state.
|
||||
if: github.event_name != 'pull_request_review' || (github.event.review.state == 'approved' && contains(github.event.pull_request.labels.*.name, 'backport'))
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read # read-only PR/commit lookups via the default token
|
||||
pull-requests: read # read-only PR metadata via the default token
|
||||
steps:
|
||||
- name: Collect candidate backport PRs
|
||||
id: candidates
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
PR_FROM_REVIEW: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
numbers=""
|
||||
case "$EVENT_NAME" in
|
||||
pull_request_review)
|
||||
numbers="$PR_FROM_REVIEW"
|
||||
;;
|
||||
schedule)
|
||||
# Sweep every open backport PR.
|
||||
numbers=$(gh pr list --repo "$GH_REPO" --state open --label backport \
|
||||
--limit 100 --json number --jq '.[].number')
|
||||
;;
|
||||
esac
|
||||
# De-duplicate and emit space-separated, digit-only tokens.
|
||||
numbers=$(echo "$numbers" | tr ' ' '\n' | grep -E '^[0-9]+$' | sort -u | tr '\n' ' ' || true)
|
||||
echo "numbers=${numbers}" >> "$GITHUB_OUTPUT"
|
||||
echo "Candidate PRs: '${numbers:-<none>}'"
|
||||
|
||||
- name: Merge eligible backport PRs
|
||||
if: steps.candidates.outputs.numbers != ''
|
||||
env:
|
||||
GH_REPO: ${{ github.repository }}
|
||||
# Read with the default token; merge with PR_GH_TOKEN so the merge emits
|
||||
# the events that downstream workflows (cloud-backport-tag.yaml) rely on.
|
||||
READ_TOKEN: ${{ github.token }}
|
||||
MERGE_TOKEN: ${{ secrets.PR_GH_TOKEN }}
|
||||
CANDIDATES: ${{ steps.candidates.outputs.numbers }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
is_merged() {
|
||||
[ "$(GH_TOKEN="$READ_TOKEN" gh pr view "$1" --repo "$GH_REPO" --json merged --jq '.merged' 2>/dev/null || echo false)" = "true" ]
|
||||
}
|
||||
|
||||
for pr in $CANDIDATES; do
|
||||
echo "::group::PR #${pr}"
|
||||
|
||||
info=$(GH_TOKEN="$READ_TOKEN" gh pr view "$pr" --repo "$GH_REPO" \
|
||||
--json number,state,isDraft,labels,baseRefName,reviewDecision,mergeStateStatus 2>/dev/null || echo '')
|
||||
if [ -z "$info" ]; then
|
||||
echo "Could not read PR #${pr} — skipping."; echo "::endgroup::"; continue
|
||||
fi
|
||||
|
||||
state=$(echo "$info" | jq -r '.state')
|
||||
is_draft=$(echo "$info" | jq -r '.isDraft')
|
||||
is_backport=$(echo "$info" | jq -r '[.labels[].name] | any(. == "backport")')
|
||||
base=$(echo "$info" | jq -r '.baseRefName')
|
||||
review=$(echo "$info" | jq -r '.reviewDecision')
|
||||
merge_state=$(echo "$info" | jq -r '.mergeStateStatus')
|
||||
|
||||
# Only ever act on open, non-draft, backport-labelled PRs targeting a
|
||||
# protected release branch.
|
||||
if [ "$state" != "OPEN" ] || [ "$is_draft" != "false" ] || [ "$is_backport" != "true" ]; then
|
||||
echo "Not an actionable backport PR (state=$state draft=$is_draft backport=$is_backport) — skipping."
|
||||
echo "::endgroup::"; continue
|
||||
fi
|
||||
case "$base" in
|
||||
cloud/*|core/*) : ;;
|
||||
*) echo "Base '$base' is not a release branch — skipping."; echo "::endgroup::"; continue ;;
|
||||
esac
|
||||
|
||||
# Ready = approved AND GitHub says it's mergeable with required checks green.
|
||||
# CLEAN = approved, all required checks green, mergeable, no conflict.
|
||||
# UNSTABLE = same, but a NON-required check is pending/failing — GitHub
|
||||
# still allows the merge, so we do too (matches what a human
|
||||
# clicking "Squash and merge" can do; required checks are the
|
||||
# only merge gate per the ruleset). Requiring CLEAN alone would
|
||||
# stick forever behind flaky/slow non-required checks.
|
||||
# Any other state (BLOCKED/DIRTY/BEHIND/UNKNOWN/...) => not ready; re-checked
|
||||
# by a later event or the next sweep.
|
||||
if [ "$review" != "APPROVED" ] || { [ "$merge_state" != "CLEAN" ] && [ "$merge_state" != "UNSTABLE" ]; }; then
|
||||
echo "Not yet ready (reviewDecision=$review mergeStateStatus=$merge_state) — will re-check later."
|
||||
echo "::endgroup::"; continue
|
||||
fi
|
||||
|
||||
echo "PR #${pr} is ready — attempting squash merge."
|
||||
attempt=0
|
||||
max=3
|
||||
merged=false
|
||||
while [ "$attempt" -lt "$max" ]; do
|
||||
attempt=$((attempt + 1))
|
||||
# A concurrent run (or a human) may have merged it already.
|
||||
if is_merged "$pr"; then merged=true; break; fi
|
||||
if out=$(GH_TOKEN="$MERGE_TOKEN" gh pr merge "$pr" --repo "$GH_REPO" --squash 2>&1); then
|
||||
merged=true; break
|
||||
fi
|
||||
echo "Merge attempt ${attempt}/${max} failed: ${out}"
|
||||
# No sleep after the final attempt.
|
||||
[ "$attempt" -lt "$max" ] && sleep $((attempt * 15))
|
||||
done
|
||||
|
||||
# Final reconciliation: a failed merge command may just mean a concurrent
|
||||
# run won the race — don't post a false failure if the PR is in fact merged.
|
||||
if [ "$merged" != "true" ] && is_merged "$pr"; then merged=true; fi
|
||||
|
||||
if [ "$merged" = "true" ]; then
|
||||
echo "PR #${pr} merged."
|
||||
else
|
||||
echo "::warning::PR #${pr} looked ready but did not merge after ${max} attempts."
|
||||
# Avoid spamming a persistently-stuck PR: only re-warn if the last
|
||||
# warning (identified by its marker) is more than an hour old.
|
||||
marker='<!-- backport-auto-merge:merge-failed -->'
|
||||
# `gh api --paginate` emits one JSON array per page; `--jq` would run
|
||||
# per page (missing the true latest across pages), so slurp all pages
|
||||
# into one array first and filter with a separate jq pass.
|
||||
last_warned=$(GH_TOKEN="$READ_TOKEN" gh api "repos/${GH_REPO}/issues/${pr}/comments" --paginate 2>/dev/null \
|
||||
| jq -s "[.[][] | select(.body | contains(\"${marker}\"))] | sort_by(.created_at) | last | .created_at // empty") || last_warned=''
|
||||
stale=true
|
||||
if [ -n "$last_warned" ]; then
|
||||
last_epoch=$(date -d "$last_warned" +%s 2>/dev/null || echo 0)
|
||||
now_epoch=$(date -u +%s)
|
||||
[ $((now_epoch - last_epoch)) -lt 3600 ] && stale=false
|
||||
fi
|
||||
if [ "$stale" = "true" ]; then
|
||||
body=$(printf '%s\n\n%s' \
|
||||
"This backport PR is approved and its required checks are green, but automatic merge failed after ${max} attempts. Please merge manually or investigate (possible branch-protection mismatch)." \
|
||||
"$marker")
|
||||
GH_TOKEN="$MERGE_TOKEN" gh pr comment "$pr" --repo "$GH_REPO" --body "$body" || true
|
||||
else
|
||||
echo "Already warned within the last hour — skipping duplicate comment."
|
||||
fi
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
done
|
||||
42
.github/workflows/cla.yml
vendored
@@ -6,6 +6,7 @@ on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, closed]
|
||||
merge_group:
|
||||
types: [checks_requested]
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
@@ -17,13 +18,45 @@ jobs:
|
||||
cla-assistant:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: CLA already verified before merge queue
|
||||
if: github.event_name == 'merge_group'
|
||||
run: echo "CLA is checked on the pull request before it enters merge queue."
|
||||
|
||||
# The CLA action normally requires every commit author in a PR to sign.
|
||||
# We only want the PR author to sign, so we allowlist all other committers
|
||||
# by computing them from the PR's commits and excluding the PR author.
|
||||
- name: Build author-only allowlist
|
||||
id: allowlist
|
||||
if: >
|
||||
github.event_name == 'pull_request_target' ||
|
||||
(github.event_name == 'issue_comment' && github.event.issue.pull_request && (
|
||||
github.event.comment.body == 'recheck' ||
|
||||
github.event.comment.body == 'I have read and agree to the Contributor License Agreement'
|
||||
))
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
|
||||
PR_AUTHOR: ${{ github.event.pull_request.user.login || github.event.issue.user.login }}
|
||||
BASE_ALLOWLIST: action@github.com,actions-user,ampagent,claude,comfy-pr-bot,GitHub Action,github-actions,github-actions[bot],Glary Bot,Glary-Bot,*[bot]
|
||||
run: |
|
||||
others=$(gh api "repos/${{ github.repository }}/pulls/${PR_NUMBER}/commits" --paginate \
|
||||
--jq '.[] | (.author.login // empty), (.committer.login // empty)' \
|
||||
| sort -u | grep -vix "${PR_AUTHOR}" | paste -sd, -)
|
||||
if [ -n "$others" ]; then
|
||||
echo "allowlist=${BASE_ALLOWLIST},${others}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "allowlist=${BASE_ALLOWLIST}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: CLA Assistant
|
||||
# Run on PR events, on "recheck" comment, or when someone posts the exact signing phrase.
|
||||
# IMPORTANT: this phrase must match `custom-pr-sign-comment` below.
|
||||
if: >
|
||||
github.event_name == 'pull_request_target' ||
|
||||
github.event.comment.body == 'recheck' ||
|
||||
github.event.comment.body == 'I have read and agree to the Contributor License Agreement'
|
||||
(github.event_name == 'issue_comment' && github.event.issue.pull_request && (
|
||||
github.event.comment.body == 'recheck' ||
|
||||
github.event.comment.body == 'I have read and agree to the Contributor License Agreement'
|
||||
))
|
||||
uses: contributor-assistant/github-action@ca4a40a7d1004f18d9960b404b97e5f30a505a08 # v2.6.1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -39,9 +72,10 @@ jobs:
|
||||
path-to-signatures: signatures/cla.json
|
||||
branch: main
|
||||
|
||||
# Allowlist bots so they don't need to sign (optional, comma-separated).
|
||||
# Only the PR author must sign: bots plus every non-author committer
|
||||
# are allowlisted via the "Build author-only allowlist" step above.
|
||||
# *[bot] is a catch-all for any GitHub App bot account.
|
||||
allowlist: action@github.com,actions-user,ampagent,claude,comfy-pr-bot,GitHub Action,github-actions,Glary Bot,Glary-Bot,*[bot]
|
||||
allowlist: ${{ steps.allowlist.outputs.allowlist }}
|
||||
|
||||
# Custom PR comment messages
|
||||
custom-notsigned-prcomment: |
|
||||
|
||||
@@ -40,7 +40,7 @@ test.describe('Cloud page @smoke', () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('AIModelsSection heading and 5 model cards are visible', async ({
|
||||
test('AIModelsSection heading and 6 model cards are visible', async ({
|
||||
page
|
||||
}) => {
|
||||
const heading = page.getByRole('heading', { name: /leading AI models/i })
|
||||
@@ -49,7 +49,7 @@ test.describe('Cloud page @smoke', () => {
|
||||
const section = heading.locator('xpath=ancestor::section')
|
||||
const grid = section.locator('.grid')
|
||||
const modelCards = grid.locator('a[href="https://comfy.org/workflows"]')
|
||||
await expect(modelCards).toHaveCount(5)
|
||||
await expect(modelCards).toHaveCount(6)
|
||||
})
|
||||
|
||||
test('AIModelsSection CTA links to workflows', async ({ page }) => {
|
||||
|
||||
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 938 B |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 1.2 KiB |
10
apps/website/public/icons/ai-models/openai.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1483_15836)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M196.373 184.704V136.491C196.373 132.437 197.909 129.387 201.451 127.36L298.368 71.552C311.573 63.936 327.296 60.3947 343.531 60.3947C404.416 60.3947 442.987 107.584 442.987 157.803C442.987 161.365 442.987 165.419 442.475 169.472L341.995 110.613C339.266 108.876 336.099 107.954 332.864 107.954C329.629 107.954 326.462 108.876 323.733 110.613L196.373 184.704ZM422.699 372.437V257.28C422.699 250.176 419.648 245.12 413.547 241.557L286.187 167.467L327.787 143.616C329.294 142.624 331.059 142.095 332.864 142.095C334.669 142.095 336.434 142.624 337.941 143.616L434.859 199.445C462.784 215.659 481.557 250.176 481.557 283.669C481.557 322.24 458.731 357.76 422.677 372.48L422.699 372.437ZM166.443 270.997L124.843 246.635C121.28 244.608 119.744 241.557 119.744 237.504V125.845C119.744 71.552 161.344 30.4427 217.685 30.4427C239.019 30.4427 258.795 37.5467 275.541 50.24L175.573 108.096C169.493 111.637 166.443 116.715 166.443 123.819V270.976V270.997ZM256 322.731L196.373 289.237V218.197L256 184.704L315.627 218.197V289.237L256 322.731ZM294.315 476.971C272.981 476.971 253.205 469.888 236.459 457.195L336.427 399.339C342.507 395.797 345.557 390.72 345.557 383.616V236.459L387.669 260.821C391.232 262.848 392.747 265.899 392.747 269.952V381.589C392.747 435.883 350.635 476.971 294.315 476.971ZM174.059 363.84L77.12 308.011C49.216 291.776 30.4427 257.28 30.4427 223.787C30.3769 204.756 35.9917 186.138 46.5684 170.317C57.1451 154.495 72.2025 142.19 89.8133 134.976V250.667C89.8133 257.771 92.864 262.848 98.944 266.411L225.813 339.989L184.213 363.84C182.707 364.835 180.941 365.365 179.136 365.365C177.331 365.365 175.565 364.835 174.059 363.84ZM168.469 447.04C111.125 447.04 69.0133 403.925 69.0133 350.635C69.0133 346.581 69.5253 342.528 70.016 338.475L169.984 396.288C176.085 399.851 182.165 399.851 188.245 396.288L315.605 322.731V370.944C315.605 374.997 314.112 378.048 310.549 380.075L213.632 435.883C200.427 443.499 184.704 447.04 168.469 447.04ZM294.315 507.413C323.553 507.416 351.895 497.319 374.547 478.831C397.198 460.343 412.768 434.598 418.624 405.952C475.456 391.232 512 337.92 512 283.648C512 248.128 496.789 213.632 469.376 188.757C471.915 178.091 473.429 167.445 473.429 156.8C473.429 84.2453 414.571 29.9307 346.581 29.9307C332.885 29.9307 319.701 31.9573 306.475 36.544C282.795 13.2354 250.933 0.118797 217.707 1.37049e-07C188.465 -0.00135846 160.121 10.0985 137.469 28.5908C114.817 47.0831 99.2486 72.8325 93.3973 101.483C36.544 116.203 0 169.493 0 223.787C0 259.328 15.2107 293.824 42.624 318.677C40.0853 329.344 38.5707 340.011 38.5707 350.656C38.5707 423.211 97.4293 477.504 165.419 477.504C179.115 477.504 192.299 475.477 205.525 470.912C229.208 494.23 261.08 507.347 294.315 507.456V507.413Z" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1483_15836">
|
||||
<rect width="512" height="512" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
28
apps/website/src/components/common/CardArrow.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { ChevronRight } from '@lucide/vue'
|
||||
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
const { hover = 'self', class: className } = defineProps<{
|
||||
hover?: 'self' | 'group'
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex size-10 items-center justify-center rounded-2xl bg-white/20 text-white backdrop-blur-sm transition-colors',
|
||||
hover === 'group'
|
||||
? 'group-hover:bg-primary-comfy-yellow group-hover:text-primary-comfy-ink'
|
||||
: 'hover:bg-primary-comfy-yellow hover:text-primary-comfy-ink',
|
||||
className
|
||||
)
|
||||
"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<ChevronRight class="size-5" :stroke-width="2" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import Button from '../ui/button/Button.vue'
|
||||
|
||||
const { title, description, cta, href, bg } = defineProps<{
|
||||
title: string
|
||||
description: string
|
||||
@@ -28,11 +30,9 @@ const { title, description, cta, href, bg } = defineProps<{
|
||||
<p class="text-sm text-white/70">
|
||||
{{ description }}
|
||||
</p>
|
||||
<span
|
||||
class="bg-primary-comfy-yellow text-primary-comfy-ink mt-4 inline-block rounded-xl px-4 py-2 text-xs font-bold tracking-wide"
|
||||
>
|
||||
<Button as="span" variant="default" size="sm" class="mt-4">
|
||||
{{ cta }}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</a>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../../i18n/translations'
|
||||
|
||||
import { externalLinks } from '../../../config/routes'
|
||||
import { t } from '../../../i18n/translations'
|
||||
import CardArrow from '../../common/CardArrow.vue'
|
||||
import GlassCard from '../../common/GlassCard.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
@@ -27,7 +29,7 @@ const cards = [
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-4 pt-24 lg:px-20 lg:pt-40">
|
||||
<h2
|
||||
class="text-primary-comfy-canvas text-3.5xl/tight mx-auto max-w-3xl text-center font-light lg:text-5xl/tight"
|
||||
class="text-3.5xl/tight mx-auto max-w-3xl text-center font-light text-primary-comfy-canvas lg:text-5xl/tight"
|
||||
>
|
||||
{{ headingParts[0]
|
||||
}}<span class="text-white">{{
|
||||
@@ -37,10 +39,11 @@ const cards = [
|
||||
</h2>
|
||||
|
||||
<GlassCard class="mt-12 grid grid-cols-1 gap-6 lg:mt-20 lg:grid-cols-2">
|
||||
<div
|
||||
<a
|
||||
v-for="card in cards"
|
||||
:key="card.labelKey"
|
||||
class="bg-primary-comfy-ink rounded-4.5xl overflow-hidden"
|
||||
:href="externalLinks.cloud"
|
||||
class="group rounded-4.5xl block overflow-hidden bg-primary-comfy-ink"
|
||||
>
|
||||
<img
|
||||
:src="card.image"
|
||||
@@ -51,23 +54,27 @@ const cards = [
|
||||
/>
|
||||
|
||||
<div class="mt-8 p-6">
|
||||
<p
|
||||
class="text-primary-comfy-yellow text-sm font-bold tracking-widest uppercase"
|
||||
>
|
||||
{{ t(card.labelKey, locale) }}
|
||||
</p>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<p
|
||||
class="text-primary-comfy-yellow text-sm font-bold tracking-widest uppercase"
|
||||
>
|
||||
{{ t(card.labelKey, locale) }}
|
||||
</p>
|
||||
|
||||
<CardArrow hover="group" class="shrink-0" />
|
||||
</div>
|
||||
|
||||
<h3
|
||||
class="text-primary-comfy-canvas mt-8 text-3xl/tight font-light whitespace-pre-line"
|
||||
class="mt-8 text-3xl/tight font-light whitespace-pre-line text-primary-comfy-canvas"
|
||||
>
|
||||
{{ t(card.titleKey, locale) }}
|
||||
</h3>
|
||||
|
||||
<p class="text-primary-comfy-canvas mt-8 text-base/normal">
|
||||
<p class="mt-8 text-base/normal text-primary-comfy-canvas">
|
||||
{{ t(card.descriptionKey, locale) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</GlassCard>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -17,18 +17,18 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
>
|
||||
<div class="max-w-2xl">
|
||||
<h2
|
||||
class="text-primary-comfy-ink text-2xl/tight font-medium lg:text-3xl/tight"
|
||||
class="text-2xl/tight font-medium text-primary-comfy-ink lg:text-3xl/tight"
|
||||
>
|
||||
{{ t('cloud.pricing.title', locale) }}
|
||||
</h2>
|
||||
|
||||
<p class="text-primary-comfy-ink mt-4 text-base">
|
||||
<p class="mt-4 text-base text-primary-comfy-ink">
|
||||
{{ t('cloud.pricing.description', locale) }}
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="SHOW_FREE_TIER"
|
||||
class="text-primary-comfy-ink mt-4 text-base font-bold"
|
||||
class="mt-4 text-base font-bold text-primary-comfy-ink"
|
||||
>
|
||||
{{ t('cloud.pricing.tagline', locale) }}
|
||||
</p>
|
||||
@@ -36,7 +36,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
<a
|
||||
:href="getRoutes(locale).cloudPricing"
|
||||
class="bg-primary-comfy-ink text-primary-comfy-yellow shrink-0 rounded-2xl px-6 py-3 text-center text-sm font-semibold transition-opacity hover:opacity-90"
|
||||
class="text-primary-comfy-yellow shrink-0 rounded-2xl bg-primary-comfy-ink px-6 py-3 text-center text-sm font-semibold transition-opacity hover:opacity-90"
|
||||
>
|
||||
{{ t('cloud.pricing.cta', locale) }}
|
||||
</a>
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { Locale } from '../../../i18n/translations'
|
||||
import { externalLinks } from '../../../config/routes'
|
||||
import { t } from '../../../i18n/translations'
|
||||
import BrandButton from '../../common/BrandButton.vue'
|
||||
import CardArrow from '../../common/CardArrow.vue'
|
||||
|
||||
type ModelCard = {
|
||||
titleKey:
|
||||
@@ -14,11 +15,10 @@ type ModelCard = {
|
||||
| 'cloud.aiModels.card.seedance20'
|
||||
| 'cloud.aiModels.card.qwenImageEdit'
|
||||
| 'cloud.aiModels.card.wan22TextToVideo'
|
||||
| 'cloud.aiModels.card.gptImage2'
|
||||
imageSrc: string
|
||||
badgeIcon: string
|
||||
badgeClass: string
|
||||
layoutClass: string
|
||||
objectPosition?: string
|
||||
}
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
@@ -32,48 +32,45 @@ const modelCards: ModelCard[] = [
|
||||
imageSrc:
|
||||
'https://media.comfy.org/website/cloud/ai-models/seedance-20.webm',
|
||||
badgeIcon: '/icons/ai-models/bytedance.svg',
|
||||
badgeClass: `${badgeBase} rounded-2xl`,
|
||||
layoutClass: 'lg:col-span-6 lg:aspect-[16/7]'
|
||||
badgeClass: `${badgeBase} rounded-2xl`
|
||||
},
|
||||
{
|
||||
titleKey: 'cloud.aiModels.card.nanoBananaPro',
|
||||
imageSrc:
|
||||
'https://media.comfy.org/website/cloud/ai-models/nano-banana-pro.webp',
|
||||
badgeIcon: '/icons/ai-models/gemini.svg',
|
||||
badgeClass: `${badgeBase} rounded-2xl`,
|
||||
layoutClass: 'lg:col-span-6 lg:aspect-[16/7]',
|
||||
objectPosition: 'center 20%'
|
||||
badgeClass: `${badgeBase} rounded-2xl`
|
||||
},
|
||||
{
|
||||
titleKey: 'cloud.aiModels.card.grokImagine',
|
||||
imageSrc: 'https://media.comfy.org/website/cloud/ai-models/grok-video.webm',
|
||||
badgeIcon: '/icons/ai-models/grok.svg',
|
||||
badgeClass: `${badgeBase} rounded-2xl`,
|
||||
layoutClass: 'lg:col-span-4 lg:aspect-[4/3]'
|
||||
badgeClass: `${badgeBase} rounded-2xl`
|
||||
},
|
||||
{
|
||||
titleKey: 'cloud.aiModels.card.qwenImageEdit',
|
||||
imageSrc:
|
||||
'https://media.comfy.org/website/cloud/ai-models/qwen-image-edit.webp',
|
||||
badgeIcon: '/icons/ai-models/qwen.svg',
|
||||
badgeClass: `${badgeBase} rounded-2xl`,
|
||||
layoutClass: 'lg:col-span-4 lg:aspect-[4/3]'
|
||||
badgeClass: `${badgeBase} rounded-2xl`
|
||||
},
|
||||
{
|
||||
titleKey: 'cloud.aiModels.card.wan22TextToVideo',
|
||||
imageSrc: 'https://media.comfy.org/website/cloud/ai-models/wan-22.webm',
|
||||
badgeIcon: '/icons/ai-models/wan.svg',
|
||||
badgeClass: `${badgeBase} rounded-2xl`,
|
||||
layoutClass: 'lg:col-span-4 lg:aspect-[4/3]'
|
||||
badgeClass: `${badgeBase} rounded-2xl`
|
||||
},
|
||||
{
|
||||
titleKey: 'cloud.aiModels.card.gptImage2',
|
||||
imageSrc:
|
||||
'https://media.comfy.org/website/cloud/ai-models/gpt-image-2.webm',
|
||||
badgeIcon: '/icons/ai-models/openai.svg',
|
||||
badgeClass: `${badgeBase} rounded-2xl`
|
||||
}
|
||||
]
|
||||
|
||||
function getCardClass(layoutClass: string): string {
|
||||
return cn(
|
||||
layoutClass,
|
||||
'group relative h-72 cursor-pointer overflow-hidden rounded-4xl bg-black/40 lg:h-auto'
|
||||
)
|
||||
}
|
||||
const cardClass =
|
||||
'group relative h-72 cursor-pointer overflow-hidden rounded-3xl bg-black/40 lg:col-span-4 lg:aspect-square lg:h-auto'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -100,23 +97,18 @@ function getCardClass(layoutClass: string): string {
|
||||
</p>
|
||||
|
||||
<div class="mt-16 w-full lg:mt-24">
|
||||
<div class="rounded-4xl border border-white/12 p-2 lg:p-1.5">
|
||||
<div class="rounded-4xl bg-white/8 p-2 lg:p-1.5">
|
||||
<div class="grid grid-cols-1 gap-2 lg:grid-cols-12">
|
||||
<a
|
||||
v-for="card in modelCards"
|
||||
:key="card.titleKey"
|
||||
:href="externalLinks.workflows"
|
||||
:class="getCardClass(card.layoutClass)"
|
||||
:class="cardClass"
|
||||
>
|
||||
<video
|
||||
v-if="card.imageSrc.endsWith('.webm')"
|
||||
:src="card.imageSrc"
|
||||
:aria-label="t(card.titleKey, locale)"
|
||||
:style="
|
||||
card.objectPosition
|
||||
? { objectPosition: card.objectPosition }
|
||||
: undefined
|
||||
"
|
||||
class="size-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
autoplay
|
||||
loop
|
||||
@@ -134,11 +126,6 @@ function getCardClass(layoutClass: string): string {
|
||||
v-else
|
||||
:src="card.imageSrc"
|
||||
:alt="t(card.titleKey, locale)"
|
||||
:style="
|
||||
card.objectPosition
|
||||
? { objectPosition: card.objectPosition }
|
||||
: undefined
|
||||
"
|
||||
class="size-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
@@ -168,10 +155,14 @@ function getCardClass(layoutClass: string): string {
|
||||
</div>
|
||||
|
||||
<p
|
||||
class="text-primary-warm-white absolute inset-x-6 bottom-6 text-2xl/tight font-light whitespace-pre-line drop-shadow-[0_2px_8px_rgba(0,0,0,0.9)] lg:top-6 lg:right-auto lg:bottom-auto lg:text-3xl"
|
||||
class="text-primary-warm-white absolute right-20 bottom-6 left-6 text-2xl/tight font-light whitespace-pre-line drop-shadow-[0_2px_8px_rgba(0,0,0,0.9)] lg:top-6 lg:right-auto lg:bottom-auto lg:text-3xl"
|
||||
>
|
||||
{{ t(card.titleKey, locale) }}
|
||||
</p>
|
||||
|
||||
<CardArrow
|
||||
class="absolute right-5 bottom-5 lg:right-6 lg:bottom-6"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -932,9 +932,9 @@ const translations = {
|
||||
'zh-CN': '所有模型。\n商业许可保证。'
|
||||
},
|
||||
'cloud.reason.2.description': {
|
||||
en: 'Run open-source models like Wan 2.2, Flux, LTX and Qwen alongside partner models like Nano Banana, Seedance, Seedream, Grok, Kling, Hunyuan 3D and more. Every model on Comfy Cloud is cleared for commercial use. No license ambiguity. All through one credit balance.',
|
||||
en: 'Run open-source models like Wan 2.2, Flux, LTX and Qwen alongside partner models like Nano Banana, Seedance, Seedream, Grok, Kling, Hunyuan 3D, GPT Image 2 and more. Every model on Comfy Cloud is cleared for commercial use. No license ambiguity. All through one credit balance.',
|
||||
'zh-CN':
|
||||
'运行 Wan 2.2、Flux、LTX 和 Qwen 等开源模型,以及 Nano Banana、Seedance、Seedream、Grok、Kling、Hunyuan 3D 等合作伙伴模型。Comfy Cloud 上的每个模型都已获得商业使用许可。无许可证歧义。通过统一的积分余额使用。'
|
||||
'运行 Wan 2.2、Flux、LTX 和 Qwen 等开源模型,以及 Nano Banana、Seedance、Seedream、Grok、Kling、Hunyuan 3D、GPT Image 2 等合作伙伴模型。Comfy Cloud 上的每个模型都已获得商业使用许可。无许可证歧义。通过统一的积分余额使用。'
|
||||
},
|
||||
'cloud.reason.2.badge.onlyOn': {
|
||||
en: 'ONLY ON',
|
||||
@@ -996,6 +996,10 @@ const translations = {
|
||||
en: 'Wan 2.2',
|
||||
'zh-CN': 'Wan 2.2'
|
||||
},
|
||||
'cloud.aiModels.card.gptImage2': {
|
||||
en: 'GPT Image 2',
|
||||
'zh-CN': 'GPT Image 2'
|
||||
},
|
||||
'cloud.aiModels.ctaDesktop': {
|
||||
en: 'EXPLORE WORKFLOWS WITH THE LATEST MODELS',
|
||||
'zh-CN': '探索最新模型工作流'
|
||||
|
||||
@@ -248,7 +248,7 @@
|
||||
@utility ppformula-text-center {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
top: 0.19em;
|
||||
top: 0.1em;
|
||||
}
|
||||
|
||||
/* Hide native play-button overlay iOS Safari shows when autoplay is blocked
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
describe('runWhenGlobalIdle', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('falls back to a timeout when idle callbacks are unavailable', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.stubGlobal('requestIdleCallback', undefined)
|
||||
vi.stubGlobal('cancelIdleCallback', undefined)
|
||||
const { runWhenGlobalIdle } = await import('./async')
|
||||
const runner = vi.fn()
|
||||
|
||||
const disposable = runWhenGlobalIdle(runner)
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(runner).toHaveBeenCalledOnce()
|
||||
const deadline = runner.mock.calls[0][0]
|
||||
expect(deadline.didTimeout).toBe(true)
|
||||
expect(deadline.timeRemaining()).toBeGreaterThanOrEqual(0)
|
||||
|
||||
disposable.dispose()
|
||||
disposable.dispose()
|
||||
})
|
||||
|
||||
it('cancels fallback idle work before it runs', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.stubGlobal('requestIdleCallback', undefined)
|
||||
vi.stubGlobal('cancelIdleCallback', undefined)
|
||||
const { runWhenGlobalIdle } = await import('./async')
|
||||
const runner = vi.fn()
|
||||
|
||||
runWhenGlobalIdle(runner).dispose()
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(runner).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses native idle callbacks when available', async () => {
|
||||
const requestIdleCallback = vi.fn(() => 42)
|
||||
const cancelIdleCallback = vi.fn()
|
||||
vi.stubGlobal('requestIdleCallback', requestIdleCallback)
|
||||
vi.stubGlobal('cancelIdleCallback', cancelIdleCallback)
|
||||
const { runWhenGlobalIdle } = await import('./async')
|
||||
const runner = vi.fn()
|
||||
|
||||
const disposable = runWhenGlobalIdle(runner, 250)
|
||||
|
||||
expect(requestIdleCallback).toHaveBeenCalledWith(runner, { timeout: 250 })
|
||||
|
||||
disposable.dispose()
|
||||
disposable.dispose()
|
||||
|
||||
expect(cancelIdleCallback).toHaveBeenCalledOnce()
|
||||
expect(cancelIdleCallback).toHaveBeenCalledWith(42)
|
||||
})
|
||||
|
||||
it('omits native idle timeout options when no timeout is supplied', async () => {
|
||||
const requestIdleCallback = vi.fn(() => 7)
|
||||
vi.stubGlobal('requestIdleCallback', requestIdleCallback)
|
||||
vi.stubGlobal('cancelIdleCallback', vi.fn())
|
||||
const { runWhenGlobalIdle } = await import('./async')
|
||||
const runner = vi.fn()
|
||||
|
||||
runWhenGlobalIdle(runner)
|
||||
|
||||
expect(requestIdleCallback).toHaveBeenCalledWith(runner, undefined)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
@@ -122,22 +122,6 @@ describe('downloadUtil', () => {
|
||||
expect(createObjectURLSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('throws for an empty URL', () => {
|
||||
expect(() => downloadFile('')).toThrow(
|
||||
'Invalid URL provided for download'
|
||||
)
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
expect(createObjectURLSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('throws for a whitespace URL', () => {
|
||||
expect(() => downloadFile(' ')).toThrow(
|
||||
'Invalid URL provided for download'
|
||||
)
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
expect(createObjectURLSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should prefer custom filename over extracted filename', () => {
|
||||
const testUrl =
|
||||
'https://example.com/api/file?filename=extracted-image.jpg'
|
||||
@@ -355,7 +339,7 @@ describe('downloadUtil', () => {
|
||||
const testUrl = 'https://storage.googleapis.com/bucket/image.png'
|
||||
const blob = new Blob(['test'], { type: 'image/png' })
|
||||
const mockTab = { location: { href: '' }, closed: false, close: vi.fn() }
|
||||
windowOpenSpy.mockReturnValue(fromPartial<Window>(mockTab))
|
||||
windowOpenSpy.mockReturnValue(fromAny<Window, unknown>(mockTab))
|
||||
fetchMock.mockResolvedValue(
|
||||
fromPartial<Response>({
|
||||
ok: true,
|
||||
@@ -375,7 +359,7 @@ describe('downloadUtil', () => {
|
||||
mockIsCloud.value = true
|
||||
const blob = new Blob(['test'], { type: 'image/png' })
|
||||
const mockTab = { location: { href: '' }, closed: false, close: vi.fn() }
|
||||
windowOpenSpy.mockReturnValue(fromPartial<Window>(mockTab))
|
||||
windowOpenSpy.mockReturnValue(fromAny<Window, unknown>(mockTab))
|
||||
fetchMock.mockResolvedValue(
|
||||
fromPartial<Response>({
|
||||
ok: true,
|
||||
@@ -395,7 +379,7 @@ describe('downloadUtil', () => {
|
||||
const testUrl = 'https://storage.googleapis.com/bucket/missing.png'
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const mockTab = { location: { href: '' }, closed: false, close: vi.fn() }
|
||||
windowOpenSpy.mockReturnValue(fromPartial<Window>(mockTab))
|
||||
windowOpenSpy.mockReturnValue(fromAny<Window, unknown>(mockTab))
|
||||
fetchMock.mockResolvedValue(
|
||||
fromPartial<Response>({ ok: false, status: 404 })
|
||||
)
|
||||
@@ -411,7 +395,7 @@ describe('downloadUtil', () => {
|
||||
mockIsCloud.value = true
|
||||
const blob = new Blob(['test'], { type: 'image/png' })
|
||||
const mockTab = { location: { href: '' }, closed: true, close: vi.fn() }
|
||||
windowOpenSpy.mockReturnValue(fromPartial<Window>(mockTab))
|
||||
windowOpenSpy.mockReturnValue(fromAny<Window, unknown>(mockTab))
|
||||
fetchMock.mockResolvedValue(
|
||||
fromPartial<Response>({
|
||||
ok: true,
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
CREDITS_PER_USD,
|
||||
COMFY_CREDIT_RATE_CENTS,
|
||||
centsToCredits,
|
||||
clampUsd,
|
||||
creditsToCents,
|
||||
creditsToUsd,
|
||||
formatCredits,
|
||||
@@ -44,21 +43,4 @@ describe('comfyCredits helpers', () => {
|
||||
expect(formatCreditsFromUsd({ usd: 1, locale })).toBe('211.00')
|
||||
expect(formatUsd({ value: 4.2, locale })).toBe('4.20')
|
||||
})
|
||||
|
||||
test('formats with compatible fraction digit bounds', () => {
|
||||
expect(
|
||||
formatCredits({
|
||||
value: 12.345,
|
||||
locale: 'en-US',
|
||||
numberOptions: { minimumFractionDigits: 4, maximumFractionDigits: 2 }
|
||||
})
|
||||
).toBe('12.35')
|
||||
})
|
||||
|
||||
test('clamps USD purchase values into the supported range', () => {
|
||||
expect(clampUsd(Number.NaN)).toBe(0)
|
||||
expect(clampUsd(-5)).toBe(1)
|
||||
expect(clampUsd(42)).toBe(42)
|
||||
expect(clampUsd(5000)).toBe(1000)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,16 +8,7 @@ import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workfl
|
||||
type ModifiedWorkflow = Pick<ComfyWorkflow, 'path' | 'isModified'>
|
||||
|
||||
const mockAuthStore = vi.hoisted(() => ({
|
||||
logout: vi.fn().mockResolvedValue(undefined),
|
||||
sendPasswordReset: vi.fn().mockResolvedValue(undefined),
|
||||
initiateCreditPurchase: vi.fn(),
|
||||
accessBillingPortal: vi.fn(),
|
||||
fetchBalance: vi.fn(),
|
||||
loginWithGoogle: vi.fn(),
|
||||
loginWithGithub: vi.fn(),
|
||||
login: vi.fn(),
|
||||
register: vi.fn(),
|
||||
updatePassword: vi.fn().mockResolvedValue(undefined)
|
||||
logout: vi.fn().mockResolvedValue(undefined)
|
||||
}))
|
||||
|
||||
const mockToastStore = vi.hoisted(() => ({
|
||||
@@ -38,16 +29,6 @@ const mockDialogService = vi.hoisted(() => ({
|
||||
|
||||
const mockToastErrorHandler = vi.hoisted(() => vi.fn())
|
||||
|
||||
const mockBillingContext = vi.hoisted(() => ({
|
||||
isActiveSubscription: { value: false },
|
||||
isFreeTier: { value: true },
|
||||
type: { value: 'free' }
|
||||
}))
|
||||
|
||||
const mockTelemetry = vi.hoisted(() => ({
|
||||
startTopupTracking: vi.fn()
|
||||
}))
|
||||
|
||||
const knownAuthErrorCodes = new Set([
|
||||
'auth/invalid-credential',
|
||||
'auth/email-already-in-use'
|
||||
@@ -67,7 +48,7 @@ vi.mock('@/platform/distribution/types', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: vi.fn(() => mockTelemetry)
|
||||
useTelemetry: vi.fn(() => undefined)
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
@@ -91,7 +72,11 @@ vi.mock('@/stores/authStore', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: vi.fn(() => mockBillingContext)
|
||||
useBillingContext: vi.fn(() => ({
|
||||
isActiveSubscription: { value: false },
|
||||
isFreeTier: { value: true },
|
||||
type: { value: 'free' }
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useErrorHandling', () => ({
|
||||
@@ -112,7 +97,6 @@ describe('useAuthActions.logout', () => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
mockWorkflowStore.modifiedWorkflows = []
|
||||
mockBillingContext.isActiveSubscription.value = false
|
||||
})
|
||||
|
||||
it('logs out without prompting when no workflows are modified', async () => {
|
||||
@@ -297,158 +281,4 @@ describe('useAuthActions.reportError', () => {
|
||||
expect(mockToastErrorHandler).toHaveBeenCalledWith(networkError)
|
||||
expect(mockToastStore.add).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows the unauthorized-domain access error message', () => {
|
||||
const { reportError, accessError } = useAuthActions()
|
||||
|
||||
reportError(new FirebaseError('auth/unauthorized-domain', 'blocked'))
|
||||
|
||||
expect(accessError.value).toBe(true)
|
||||
expect(mockToastStore.add).toHaveBeenCalledWith({
|
||||
severity: 'error',
|
||||
summary: 'g.error',
|
||||
detail: 'toastMessages.unauthorizedDomain'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('useAuthActions account actions', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
mockBillingContext.isActiveSubscription.value = false
|
||||
vi.stubGlobal(
|
||||
'open',
|
||||
vi.fn(() => ({}))
|
||||
)
|
||||
})
|
||||
|
||||
it('sends password reset emails and shows success toast', async () => {
|
||||
const { sendPasswordReset } = useAuthActions()
|
||||
|
||||
await sendPasswordReset('user@example.com')
|
||||
|
||||
expect(mockAuthStore.sendPasswordReset).toHaveBeenCalledWith(
|
||||
'user@example.com'
|
||||
)
|
||||
expect(mockToastStore.add).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
severity: 'success',
|
||||
summary: 'auth.login.passwordResetSent'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('does not purchase credits without an active subscription', async () => {
|
||||
const { purchaseCredits } = useAuthActions()
|
||||
|
||||
await purchaseCredits(25)
|
||||
|
||||
expect(mockAuthStore.initiateCreditPurchase).not.toHaveBeenCalled()
|
||||
expect(window.open).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens checkout and tracks top-up starts for credit purchases', async () => {
|
||||
mockBillingContext.isActiveSubscription.value = true
|
||||
mockAuthStore.initiateCreditPurchase.mockResolvedValueOnce({
|
||||
checkout_url: 'https://checkout.example.test'
|
||||
})
|
||||
const { purchaseCredits } = useAuthActions()
|
||||
|
||||
await purchaseCredits(25)
|
||||
|
||||
expect(mockAuthStore.initiateCreditPurchase).toHaveBeenCalledWith({
|
||||
amount_micros: 25000000,
|
||||
currency: 'usd'
|
||||
})
|
||||
expect(mockTelemetry.startTopupTracking).toHaveBeenCalledOnce()
|
||||
expect(window.open).toHaveBeenCalledWith(
|
||||
'https://checkout.example.test',
|
||||
'_blank'
|
||||
)
|
||||
})
|
||||
|
||||
it('throws when credit checkout URL is missing', async () => {
|
||||
mockBillingContext.isActiveSubscription.value = true
|
||||
mockAuthStore.initiateCreditPurchase.mockResolvedValueOnce({})
|
||||
const { purchaseCredits } = useAuthActions()
|
||||
|
||||
await expect(purchaseCredits(10)).rejects.toThrow(
|
||||
'toastMessages.failedToPurchaseCredits'
|
||||
)
|
||||
})
|
||||
|
||||
it('opens the billing portal in a new tab by default', async () => {
|
||||
mockAuthStore.accessBillingPortal.mockResolvedValueOnce({
|
||||
billing_portal_url: 'https://billing.example.test'
|
||||
})
|
||||
const { accessBillingPortal } = useAuthActions()
|
||||
|
||||
await expect(accessBillingPortal('pro')).resolves.toBe(true)
|
||||
|
||||
expect(mockAuthStore.accessBillingPortal).toHaveBeenCalledWith('pro')
|
||||
expect(window.open).toHaveBeenCalledWith(
|
||||
'https://billing.example.test',
|
||||
'_blank'
|
||||
)
|
||||
})
|
||||
|
||||
it('throws when billing portal URL is missing', async () => {
|
||||
mockAuthStore.accessBillingPortal.mockResolvedValueOnce({})
|
||||
const { accessBillingPortal } = useAuthActions()
|
||||
|
||||
await expect(accessBillingPortal()).rejects.toThrow(
|
||||
'toastMessages.failedToAccessBillingPortal'
|
||||
)
|
||||
})
|
||||
|
||||
it('delegates balance and sign-in methods to the auth store', async () => {
|
||||
mockAuthStore.fetchBalance.mockResolvedValueOnce({ balance: 12 })
|
||||
mockAuthStore.loginWithGoogle.mockResolvedValueOnce('google')
|
||||
mockAuthStore.loginWithGithub.mockResolvedValueOnce('github')
|
||||
mockAuthStore.login.mockResolvedValueOnce('email')
|
||||
mockAuthStore.register.mockResolvedValueOnce('registered')
|
||||
const actions = useAuthActions()
|
||||
|
||||
await expect(actions.fetchBalance()).resolves.toEqual({ balance: 12 })
|
||||
await expect(actions.signInWithGoogle({ isNewUser: true })).resolves.toBe(
|
||||
'google'
|
||||
)
|
||||
await expect(actions.signInWithGithub({ isNewUser: false })).resolves.toBe(
|
||||
'github'
|
||||
)
|
||||
await expect(actions.signInWithEmail('u@example.com', 'pw')).resolves.toBe(
|
||||
'email'
|
||||
)
|
||||
await expect(
|
||||
actions.signUpWithEmail('u@example.com', 'pw', 'turnstile')
|
||||
).resolves.toBe('registered')
|
||||
|
||||
expect(mockAuthStore.loginWithGoogle).toHaveBeenCalledWith({
|
||||
isNewUser: true
|
||||
})
|
||||
expect(mockAuthStore.loginWithGithub).toHaveBeenCalledWith({
|
||||
isNewUser: false
|
||||
})
|
||||
expect(mockAuthStore.login).toHaveBeenCalledWith('u@example.com', 'pw')
|
||||
expect(mockAuthStore.register).toHaveBeenCalledWith(
|
||||
'u@example.com',
|
||||
'pw',
|
||||
'turnstile'
|
||||
)
|
||||
})
|
||||
|
||||
it('updates passwords and shows success toast', async () => {
|
||||
const { updatePassword } = useAuthActions()
|
||||
|
||||
await updatePassword('new-password')
|
||||
|
||||
expect(mockAuthStore.updatePassword).toHaveBeenCalledWith('new-password')
|
||||
expect(mockToastStore.add).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
severity: 'success',
|
||||
summary: 'auth.passwordUpdate.success'
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, reactive } from 'vue'
|
||||
|
||||
import type { User as FirebaseUser } from 'firebase/auth'
|
||||
|
||||
import type { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
|
||||
|
||||
type FirebaseUserMock = Pick<
|
||||
FirebaseUser,
|
||||
'uid' | 'displayName' | 'email' | 'photoURL'
|
||||
> & {
|
||||
providerData: Array<Pick<FirebaseUser['providerData'][number], 'providerId'>>
|
||||
}
|
||||
|
||||
type ApiKeyUser = NonNullable<
|
||||
ReturnType<typeof useApiKeyAuthStore>['currentUser']
|
||||
>
|
||||
|
||||
const mockStores = vi.hoisted(() => ({
|
||||
authStore: undefined as
|
||||
| undefined
|
||||
| {
|
||||
currentUser: FirebaseUserMock | null
|
||||
loading: boolean
|
||||
tokenRefreshTrigger: number
|
||||
},
|
||||
apiKeyStore: undefined as
|
||||
| undefined
|
||||
| {
|
||||
isAuthenticated: boolean
|
||||
currentUser: ApiKeyUser | null
|
||||
clearStoredApiKey: ReturnType<typeof vi.fn>
|
||||
},
|
||||
commandStore: undefined as
|
||||
| undefined
|
||||
| {
|
||||
execute: ReturnType<typeof vi.fn>
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/authStore', () => ({
|
||||
useAuthStore: () => mockStores.authStore
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/apiKeyAuthStore', () => ({
|
||||
useApiKeyAuthStore: () => mockStores.apiKeyStore
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/commandStore', () => ({
|
||||
useCommandStore: () => mockStores.commandStore
|
||||
}))
|
||||
|
||||
async function setup() {
|
||||
vi.resetModules()
|
||||
const authStore = reactive({
|
||||
currentUser: null as FirebaseUserMock | null,
|
||||
loading: false,
|
||||
tokenRefreshTrigger: 0
|
||||
})
|
||||
const apiKeyStore = reactive({
|
||||
isAuthenticated: false,
|
||||
currentUser: null as ApiKeyUser | null,
|
||||
clearStoredApiKey: vi.fn()
|
||||
})
|
||||
const commandStore = {
|
||||
execute: vi.fn()
|
||||
}
|
||||
|
||||
mockStores.authStore = authStore
|
||||
mockStores.apiKeyStore = apiKeyStore
|
||||
mockStores.commandStore = commandStore
|
||||
|
||||
const { useCurrentUser } = await import('./useCurrentUser')
|
||||
return {
|
||||
currentUser: useCurrentUser(),
|
||||
authStore,
|
||||
apiKeyStore,
|
||||
commandStore
|
||||
}
|
||||
}
|
||||
|
||||
function firebaseUser(
|
||||
providerId: string,
|
||||
overrides: Partial<FirebaseUserMock> = {}
|
||||
): FirebaseUserMock {
|
||||
return {
|
||||
uid: 'firebase-user',
|
||||
displayName: 'Firebase User',
|
||||
email: 'firebase@example.com',
|
||||
photoURL: 'https://example.com/photo.png',
|
||||
providerData: [{ providerId }],
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('useCurrentUser', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('reports logged-out state when no auth source is active', async () => {
|
||||
const { currentUser } = await setup()
|
||||
|
||||
expect(currentUser.loading).toBe(false)
|
||||
expect(currentUser.isLoggedIn.value).toBe(false)
|
||||
expect(currentUser.resolvedUserInfo.value).toBeNull()
|
||||
expect(currentUser.userDisplayName.value).toBeUndefined()
|
||||
expect(currentUser.userEmail.value).toBeUndefined()
|
||||
expect(currentUser.userPhotoUrl.value).toBeUndefined()
|
||||
expect(currentUser.providerName.value).toBeUndefined()
|
||||
expect(currentUser.providerIcon.value).toBe('pi pi-user')
|
||||
expect(currentUser.isEmailProvider.value).toBe(false)
|
||||
})
|
||||
|
||||
it('uses API key user identity before firebase identity', async () => {
|
||||
const { currentUser, authStore, apiKeyStore } = await setup()
|
||||
authStore.currentUser = firebaseUser('google.com')
|
||||
apiKeyStore.isAuthenticated = true
|
||||
apiKeyStore.currentUser = {
|
||||
id: 'api-user',
|
||||
name: 'API User',
|
||||
email: 'api@example.com'
|
||||
}
|
||||
|
||||
expect(currentUser.isLoggedIn.value).toBe(true)
|
||||
expect(currentUser.isApiKeyLogin.value).toBe(true)
|
||||
expect(currentUser.resolvedUserInfo.value).toEqual({ id: 'api-user' })
|
||||
expect(currentUser.userDisplayName.value).toBe('API User')
|
||||
expect(currentUser.userEmail.value).toBe('api@example.com')
|
||||
expect(currentUser.userPhotoUrl.value).toBeNull()
|
||||
expect(currentUser.providerName.value).toBe('Comfy API Key')
|
||||
expect(currentUser.providerIcon.value).toBe('pi pi-key')
|
||||
expect(currentUser.isEmailProvider.value).toBe(false)
|
||||
})
|
||||
|
||||
it('maps firebase provider metadata to display fields', async () => {
|
||||
const { currentUser, authStore } = await setup()
|
||||
|
||||
authStore.currentUser = firebaseUser('google.com')
|
||||
expect(currentUser.providerName.value).toBe('Google')
|
||||
expect(currentUser.providerIcon.value).toBe('pi pi-google')
|
||||
expect(currentUser.userDisplayName.value).toBe('Firebase User')
|
||||
expect(currentUser.userEmail.value).toBe('firebase@example.com')
|
||||
expect(currentUser.userPhotoUrl.value).toBe('https://example.com/photo.png')
|
||||
expect(currentUser.resolvedUserInfo.value).toEqual({ id: 'firebase-user' })
|
||||
|
||||
authStore.currentUser = firebaseUser('github.com')
|
||||
expect(currentUser.providerName.value).toBe('GitHub')
|
||||
expect(currentUser.providerIcon.value).toBe('pi pi-github')
|
||||
|
||||
authStore.currentUser = firebaseUser('password')
|
||||
expect(currentUser.providerName.value).toBe('password')
|
||||
expect(currentUser.providerIcon.value).toBe('pi pi-user')
|
||||
expect(currentUser.isEmailProvider.value).toBe(true)
|
||||
})
|
||||
|
||||
it('routes sign out through the active auth source', async () => {
|
||||
const { currentUser, apiKeyStore, commandStore } = await setup()
|
||||
|
||||
apiKeyStore.isAuthenticated = true
|
||||
apiKeyStore.currentUser = { id: 'api-user' }
|
||||
await currentUser.handleSignOut()
|
||||
expect(apiKeyStore.clearStoredApiKey).toHaveBeenCalledOnce()
|
||||
|
||||
apiKeyStore.isAuthenticated = false
|
||||
await currentUser.handleSignOut()
|
||||
expect(commandStore.execute).toHaveBeenCalledWith('Comfy.User.SignOut')
|
||||
})
|
||||
|
||||
it('opens the sign-in dialog through the command store', async () => {
|
||||
const { currentUser, commandStore } = await setup()
|
||||
|
||||
await currentUser.handleSignIn()
|
||||
|
||||
expect(commandStore.execute).toHaveBeenCalledWith(
|
||||
'Comfy.User.OpenSignInDialog'
|
||||
)
|
||||
})
|
||||
|
||||
it('runs user lifecycle callbacks for resolve, token refresh, and logout', async () => {
|
||||
const { currentUser, authStore } = await setup()
|
||||
const resolved = vi.fn()
|
||||
const tokenRefreshed = vi.fn()
|
||||
const logout = vi.fn()
|
||||
|
||||
currentUser.onUserResolved(resolved)
|
||||
currentUser.onTokenRefreshed(tokenRefreshed)
|
||||
currentUser.onUserLogout(logout)
|
||||
|
||||
authStore.currentUser = firebaseUser('google.com')
|
||||
await nextTick()
|
||||
expect(resolved.mock.calls[0][0]).toEqual({ id: 'firebase-user' })
|
||||
|
||||
authStore.tokenRefreshTrigger += 1
|
||||
await nextTick()
|
||||
expect(tokenRefreshed).toHaveBeenCalledOnce()
|
||||
|
||||
authStore.currentUser = null
|
||||
await nextTick()
|
||||
expect(logout).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('runs onUserResolved immediately when a user already exists', async () => {
|
||||
const { currentUser, apiKeyStore } = await setup()
|
||||
apiKeyStore.isAuthenticated = true
|
||||
apiKeyStore.currentUser = { id: 'api-user' }
|
||||
const resolved = vi.fn()
|
||||
|
||||
currentUser.onUserResolved(resolved)
|
||||
|
||||
expect(resolved.mock.calls[0][0]).toEqual({ id: 'api-user' })
|
||||
})
|
||||
})
|
||||
@@ -1,256 +0,0 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useLegacyBilling } from './useLegacyBilling'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
isActiveSubscription: { value: false },
|
||||
subscriptionTier: { value: null as string | null },
|
||||
subscriptionDuration: { value: null as string | null },
|
||||
subscriptionStatus: {
|
||||
value: null as null | {
|
||||
renewal_date?: string | null
|
||||
end_date?: string | null
|
||||
}
|
||||
},
|
||||
isCancelled: { value: false },
|
||||
fetchStatus: vi.fn(),
|
||||
manageSubscription: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
showSubscriptionDialog: vi.fn(),
|
||||
balance: {
|
||||
value: null as null | {
|
||||
amount_micros?: number
|
||||
currency?: string
|
||||
effective_balance_micros?: number
|
||||
prepaid_balance_micros?: number
|
||||
cloud_credit_balance_micros?: number
|
||||
}
|
||||
},
|
||||
fetchBalance: vi.fn(),
|
||||
purchaseCredits: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
|
||||
useSubscription: () => ({
|
||||
isActiveSubscription: mocks.isActiveSubscription,
|
||||
subscriptionTier: mocks.subscriptionTier,
|
||||
subscriptionDuration: mocks.subscriptionDuration,
|
||||
subscriptionStatus: mocks.subscriptionStatus,
|
||||
isCancelled: mocks.isCancelled,
|
||||
fetchStatus: mocks.fetchStatus,
|
||||
manageSubscription: mocks.manageSubscription,
|
||||
subscribe: mocks.subscribe,
|
||||
showSubscriptionDialog: mocks.showSubscriptionDialog
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/authStore', () => ({
|
||||
useAuthStore: () => ({
|
||||
get balance() {
|
||||
return mocks.balance.value
|
||||
},
|
||||
fetchBalance: mocks.fetchBalance
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/auth/useAuthActions', () => ({
|
||||
useAuthActions: () => ({
|
||||
purchaseCredits: mocks.purchaseCredits
|
||||
})
|
||||
}))
|
||||
|
||||
describe('useLegacyBilling', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
mocks.isActiveSubscription.value = false
|
||||
mocks.subscriptionTier.value = null
|
||||
mocks.subscriptionDuration.value = null
|
||||
mocks.subscriptionStatus.value = null
|
||||
mocks.isCancelled.value = false
|
||||
mocks.balance.value = null
|
||||
mocks.fetchStatus.mockResolvedValue(undefined)
|
||||
mocks.manageSubscription.mockResolvedValue(undefined)
|
||||
mocks.subscribe.mockResolvedValue(undefined)
|
||||
mocks.fetchBalance.mockResolvedValue(undefined)
|
||||
mocks.purchaseCredits.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('returns empty subscription and balance state without legacy data', () => {
|
||||
const billing = useLegacyBilling()
|
||||
|
||||
expect(billing.subscription.value).toBeNull()
|
||||
expect(billing.balance.value).toBeNull()
|
||||
expect(billing.subscriptionStatus.value).toBeNull()
|
||||
expect(billing.renewalDate.value).toBeNull()
|
||||
expect(billing.isFreeTier.value).toBe(false)
|
||||
})
|
||||
|
||||
it('maps active subscription and explicit balance fields', () => {
|
||||
mocks.isActiveSubscription.value = true
|
||||
mocks.subscriptionTier.value = 'PRO'
|
||||
mocks.subscriptionDuration.value = 'MONTHLY'
|
||||
mocks.subscriptionStatus.value = {
|
||||
renewal_date: '2026-01-01T00:00:00Z',
|
||||
end_date: '2026-02-01T00:00:00Z'
|
||||
}
|
||||
mocks.balance.value = {
|
||||
amount_micros: 500,
|
||||
currency: 'eur',
|
||||
effective_balance_micros: 400,
|
||||
prepaid_balance_micros: 300,
|
||||
cloud_credit_balance_micros: 200
|
||||
}
|
||||
|
||||
const billing = useLegacyBilling()
|
||||
|
||||
expect(billing.subscription.value).toEqual({
|
||||
isActive: true,
|
||||
tier: 'PRO',
|
||||
duration: 'MONTHLY',
|
||||
planSlug: null,
|
||||
renewalDate: '2026-01-01T00:00:00Z',
|
||||
endDate: '2026-02-01T00:00:00Z',
|
||||
isCancelled: false,
|
||||
hasFunds: true
|
||||
})
|
||||
expect(billing.balance.value).toEqual({
|
||||
amountMicros: 500,
|
||||
currency: 'eur',
|
||||
effectiveBalanceMicros: 400,
|
||||
prepaidBalanceMicros: 300,
|
||||
cloudCreditBalanceMicros: 200
|
||||
})
|
||||
expect(billing.subscriptionStatus.value).toBe('active')
|
||||
})
|
||||
|
||||
it('uses legacy balance defaults when optional fields are absent', () => {
|
||||
mocks.subscriptionTier.value = 'FREE'
|
||||
mocks.balance.value = {}
|
||||
|
||||
const billing = useLegacyBilling()
|
||||
|
||||
expect(billing.balance.value).toEqual({
|
||||
amountMicros: 0,
|
||||
currency: 'usd',
|
||||
effectiveBalanceMicros: 0,
|
||||
prepaidBalanceMicros: 0,
|
||||
cloudCreditBalanceMicros: 0
|
||||
})
|
||||
expect(billing.subscription.value?.hasFunds).toBe(false)
|
||||
})
|
||||
|
||||
it('uses amount as effective balance when only amount is present', () => {
|
||||
mocks.balance.value = { amount_micros: 250 }
|
||||
|
||||
const billing = useLegacyBilling()
|
||||
|
||||
expect(billing.balance.value?.effectiveBalanceMicros).toBe(250)
|
||||
})
|
||||
|
||||
it('reports canceled status before active status', () => {
|
||||
mocks.isActiveSubscription.value = true
|
||||
mocks.isCancelled.value = true
|
||||
|
||||
const billing = useLegacyBilling()
|
||||
|
||||
expect(billing.subscriptionStatus.value).toBe('canceled')
|
||||
})
|
||||
|
||||
it('initializes once and re-fetches zero free-tier balance', async () => {
|
||||
mocks.subscriptionTier.value = 'FREE'
|
||||
mocks.balance.value = { amount_micros: 0 }
|
||||
const billing = useLegacyBilling()
|
||||
|
||||
await billing.initialize()
|
||||
await billing.initialize()
|
||||
|
||||
expect(billing.isInitialized.value).toBe(true)
|
||||
expect(mocks.fetchStatus).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.fetchBalance).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('stores initialization error messages from Error failures', async () => {
|
||||
mocks.fetchStatus.mockRejectedValue(new Error('status failed'))
|
||||
const billing = useLegacyBilling()
|
||||
|
||||
await expect(billing.initialize()).rejects.toThrow('status failed')
|
||||
|
||||
expect(billing.error.value).toBe('status failed')
|
||||
expect(billing.isLoading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('stores fallback initialization error messages for non-Error failures', async () => {
|
||||
mocks.fetchStatus.mockRejectedValue('status failed')
|
||||
const billing = useLegacyBilling()
|
||||
|
||||
await expect(billing.initialize()).rejects.toBe('status failed')
|
||||
|
||||
expect(billing.error.value).toBe('Failed to initialize billing')
|
||||
})
|
||||
|
||||
it('stores subscription fetch fallback errors', async () => {
|
||||
mocks.fetchStatus.mockRejectedValue('status failed')
|
||||
const billing = useLegacyBilling()
|
||||
|
||||
await expect(billing.fetchStatus()).rejects.toBe('status failed')
|
||||
|
||||
expect(billing.error.value).toBe('Failed to fetch subscription')
|
||||
expect(billing.isLoading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('stores balance fetch errors', async () => {
|
||||
mocks.fetchBalance.mockRejectedValue(new Error('balance failed'))
|
||||
const billing = useLegacyBilling()
|
||||
|
||||
await expect(billing.fetchBalance()).rejects.toThrow('balance failed')
|
||||
|
||||
expect(billing.error.value).toBe('balance failed')
|
||||
expect(billing.isLoading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('stores balance fetch fallback errors', async () => {
|
||||
mocks.fetchBalance.mockRejectedValue('balance failed')
|
||||
const billing = useLegacyBilling()
|
||||
|
||||
await expect(billing.fetchBalance()).rejects.toBe('balance failed')
|
||||
|
||||
expect(billing.error.value).toBe('Failed to fetch balance')
|
||||
})
|
||||
|
||||
it('delegates legacy billing actions', async () => {
|
||||
const billing = useLegacyBilling()
|
||||
|
||||
await expect(billing.subscribe('pro-monthly')).resolves.toBeUndefined()
|
||||
await expect(billing.previewSubscribe('pro-monthly')).resolves.toBeNull()
|
||||
await billing.manageSubscription()
|
||||
await billing.cancelSubscription()
|
||||
await billing.resubscribe()
|
||||
await billing.topup(750)
|
||||
await expect(billing.fetchPlans()).resolves.toBeUndefined()
|
||||
billing.showSubscriptionDialog()
|
||||
|
||||
expect(mocks.subscribe).toHaveBeenCalledTimes(2)
|
||||
expect(mocks.manageSubscription).toHaveBeenCalledTimes(2)
|
||||
expect(mocks.purchaseCredits).toHaveBeenCalledWith(7.5)
|
||||
expect(mocks.showSubscriptionDialog).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('shows the subscription dialog when active subscription is required', async () => {
|
||||
const billing = useLegacyBilling()
|
||||
|
||||
await billing.requireActiveSubscription()
|
||||
|
||||
expect(mocks.showSubscriptionDialog).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not show the subscription dialog for active subscribers', async () => {
|
||||
mocks.isActiveSubscription.value = true
|
||||
const billing = useLegacyBilling()
|
||||
|
||||
await billing.requireActiveSubscription()
|
||||
|
||||
expect(mocks.showSubscriptionDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,235 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, defineComponent, ref } from 'vue'
|
||||
|
||||
interface MockTerminalInstance {
|
||||
cols: number
|
||||
rows: number
|
||||
options: unknown
|
||||
loadAddon: ReturnType<typeof vi.fn>
|
||||
attachCustomKeyEventHandler: ReturnType<typeof vi.fn>
|
||||
open: ReturnType<typeof vi.fn>
|
||||
dispose: ReturnType<typeof vi.fn>
|
||||
resize: ReturnType<typeof vi.fn>
|
||||
hasSelection: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
interface MockFitAddonInstance {
|
||||
proposeDimensions: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
const mockXterm = vi.hoisted(() => {
|
||||
const terminalInstances: MockTerminalInstance[] = []
|
||||
const fitAddonInstances: MockFitAddonInstance[] = []
|
||||
|
||||
class Terminal {
|
||||
cols = 80
|
||||
rows = 24
|
||||
loadAddon = vi.fn()
|
||||
attachCustomKeyEventHandler = vi.fn()
|
||||
open = vi.fn()
|
||||
dispose = vi.fn()
|
||||
resize = vi.fn((cols: number, rows: number) => {
|
||||
this.cols = cols
|
||||
this.rows = rows
|
||||
})
|
||||
hasSelection = vi.fn(() => false)
|
||||
|
||||
constructor(readonly options: unknown) {
|
||||
terminalInstances.push(this)
|
||||
}
|
||||
}
|
||||
|
||||
class FitAddon {
|
||||
proposeDimensions = vi.fn(() => ({ cols: 120, rows: 40 }))
|
||||
|
||||
constructor() {
|
||||
fitAddonInstances.push(this)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
Terminal,
|
||||
FitAddon,
|
||||
terminalInstances,
|
||||
fitAddonInstances
|
||||
}
|
||||
})
|
||||
|
||||
const mockResizeObserverInstances = [] as MockResizeObserver[]
|
||||
|
||||
class MockResizeObserver {
|
||||
observe = vi.fn()
|
||||
disconnect = vi.fn()
|
||||
|
||||
constructor(readonly callback: ResizeObserverCallback) {
|
||||
mockResizeObserverInstances.push(this)
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('@xterm/xterm', () => ({
|
||||
Terminal: mockXterm.Terminal
|
||||
}))
|
||||
|
||||
vi.mock('@xterm/addon-fit', () => ({
|
||||
FitAddon: mockXterm.FitAddon
|
||||
}))
|
||||
|
||||
vi.mock('es-toolkit/compat', () => ({
|
||||
debounce: (fn: () => void) => fn
|
||||
}))
|
||||
|
||||
const mockDistribution = vi.hoisted(() => ({ isDesktop: true }))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => mockDistribution)
|
||||
|
||||
import { useTerminal } from './useTerminal'
|
||||
|
||||
function terminalElement() {
|
||||
const element = document.createElement('div')
|
||||
Object.defineProperty(element, 'clientWidth', { value: 160 })
|
||||
Object.defineProperty(element, 'clientHeight', { value: 100 })
|
||||
return element
|
||||
}
|
||||
|
||||
function mountTerminal(
|
||||
configure?: (
|
||||
result: ReturnType<typeof useTerminal>,
|
||||
root: ReturnType<typeof ref<HTMLElement | undefined>>
|
||||
) => void
|
||||
) {
|
||||
let result: ReturnType<typeof useTerminal> | undefined
|
||||
const root = ref<HTMLElement | undefined>(terminalElement())
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup() {
|
||||
result = useTerminal(root)
|
||||
configure?.(result, root)
|
||||
return () => null
|
||||
}
|
||||
})
|
||||
)
|
||||
app.mount(document.createElement('div'))
|
||||
if (!result) throw new Error('Expected terminal composable to initialize')
|
||||
return { app, result, root }
|
||||
}
|
||||
|
||||
describe('useTerminal', () => {
|
||||
beforeEach(() => {
|
||||
mockXterm.terminalInstances.length = 0
|
||||
mockXterm.fitAddonInstances.length = 0
|
||||
mockResizeObserverInstances.length = 0
|
||||
mockDistribution.isDesktop = true
|
||||
vi.stubGlobal('ResizeObserver', MockResizeObserver)
|
||||
})
|
||||
|
||||
it('creates a desktop themed terminal and opens it on mount', () => {
|
||||
const { app, root } = mountTerminal()
|
||||
const terminal = mockXterm.terminalInstances[0]
|
||||
const fitAddon = mockXterm.fitAddonInstances[0]
|
||||
|
||||
expect(terminal.options).toMatchObject({
|
||||
convertEol: true,
|
||||
theme: { background: '#171717' }
|
||||
})
|
||||
expect(terminal.loadAddon).toHaveBeenCalledWith(fitAddon)
|
||||
expect(terminal.open).toHaveBeenCalledWith(root.value)
|
||||
|
||||
app.unmount()
|
||||
expect(terminal.dispose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('omits theme configuration when not running on desktop', () => {
|
||||
mockDistribution.isDesktop = false
|
||||
|
||||
mountTerminal()
|
||||
|
||||
const terminal = mockXterm.terminalInstances[0]
|
||||
expect(terminal.options).toEqual({ convertEol: true })
|
||||
})
|
||||
|
||||
it('lets browser copy and paste shortcuts pass through', () => {
|
||||
mountTerminal()
|
||||
const terminal = mockXterm.terminalInstances[0]
|
||||
const handler = terminal.attachCustomKeyEventHandler.mock.calls[0][0] as (
|
||||
event: KeyboardEvent
|
||||
) => boolean
|
||||
|
||||
terminal.hasSelection.mockReturnValue(true)
|
||||
expect(
|
||||
handler(new KeyboardEvent('keydown', { key: 'c', ctrlKey: true }))
|
||||
).toBe(false)
|
||||
expect(
|
||||
handler(new KeyboardEvent('keydown', { key: 'v', metaKey: true }))
|
||||
).toBe(false)
|
||||
|
||||
terminal.hasSelection.mockReturnValue(false)
|
||||
expect(
|
||||
handler(new KeyboardEvent('keydown', { key: 'c', ctrlKey: true }))
|
||||
).toBe(true)
|
||||
expect(
|
||||
handler(new KeyboardEvent('keyup', { key: 'v', ctrlKey: true }))
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('auto-sizes from fit dimensions and disconnects the observer on unmount', () => {
|
||||
const onResize = vi.fn()
|
||||
const { app, root } = mountTerminal((terminal, rootRef) => {
|
||||
terminal.useAutoSize({
|
||||
root: rootRef,
|
||||
minCols: 100,
|
||||
minRows: 20,
|
||||
onResize
|
||||
})
|
||||
})
|
||||
const terminal = mockXterm.terminalInstances[0]
|
||||
const observer = mockResizeObserverInstances[0]
|
||||
|
||||
expect(observer.observe).toHaveBeenCalledWith(root.value)
|
||||
expect(terminal.resize).toHaveBeenCalledWith(120, 40)
|
||||
expect(onResize).toHaveBeenCalledOnce()
|
||||
|
||||
app.unmount()
|
||||
expect(observer.disconnect).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('estimates invalid fit dimensions from the root element', () => {
|
||||
let resize: () => void = () => {}
|
||||
mountTerminal((terminal, rootRef) => {
|
||||
resize = terminal.useAutoSize({
|
||||
root: rootRef,
|
||||
minCols: 30,
|
||||
minRows: 10
|
||||
}).resize
|
||||
})
|
||||
const fitAddon = mockXterm.fitAddonInstances[0]
|
||||
fitAddon.proposeDimensions.mockReturnValue({
|
||||
cols: Number.NaN,
|
||||
rows: undefined
|
||||
})
|
||||
const terminal = mockXterm.terminalInstances[0]
|
||||
|
||||
resize()
|
||||
|
||||
expect(terminal.resize).toHaveBeenLastCalledWith(30, 10)
|
||||
})
|
||||
|
||||
it('keeps existing terminal dimensions when auto sizing is disabled', () => {
|
||||
let resize: () => void = () => {}
|
||||
mountTerminal((terminal, rootRef) => {
|
||||
resize = terminal.useAutoSize({
|
||||
root: rootRef,
|
||||
autoCols: false,
|
||||
autoRows: false,
|
||||
minCols: 10,
|
||||
minRows: 10
|
||||
}).resize
|
||||
})
|
||||
const terminal = mockXterm.terminalInstances[0]
|
||||
terminal.cols = 90
|
||||
terminal.rows = 30
|
||||
|
||||
resize()
|
||||
|
||||
expect(terminal.resize).toHaveBeenLastCalledWith(90, 30)
|
||||
})
|
||||
})
|
||||
@@ -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,102 +187,6 @@ describe('useBoundingBoxes drawing', () => {
|
||||
await flush()
|
||||
expect(c.modelValue.value).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('moves an existing active region by dragging inside it', async () => {
|
||||
const c = setup([box()])
|
||||
c.onPointerDown(pe(30, 30))
|
||||
c.onCanvasPointerMove(pe(45, 50))
|
||||
c.onDocPointerUp(pe(45, 50))
|
||||
await flush()
|
||||
expect(c.modelValue.value[0].x).toBeGreaterThan(51)
|
||||
expect(c.modelValue.value[0].y).toBeGreaterThan(51)
|
||||
})
|
||||
|
||||
it('resizes an existing active region from its corner handle', async () => {
|
||||
const c = setup([box()])
|
||||
c.onPointerDown(pe(60, 60))
|
||||
c.onCanvasPointerMove(pe(80, 80))
|
||||
c.onDocPointerUp(pe(80, 80))
|
||||
await flush()
|
||||
expect(c.modelValue.value[0].width).toBeGreaterThan(256)
|
||||
expect(c.modelValue.value[0].height).toBeGreaterThan(256)
|
||||
})
|
||||
|
||||
it('keeps selection valid when Alt-clicking overlapping regions', async () => {
|
||||
const c = setup([
|
||||
box(),
|
||||
box({
|
||||
metadata: {
|
||||
type: 'obj',
|
||||
text: '',
|
||||
desc: 'second',
|
||||
palette: ['#ff0000']
|
||||
}
|
||||
})
|
||||
])
|
||||
|
||||
c.onPointerDown(pe(30, 30, { altKey: true }))
|
||||
c.onDocPointerUp(pe(30, 30))
|
||||
await flush()
|
||||
|
||||
expect(c.activeRegion.value).not.toBeNull()
|
||||
expect(c.modelValue.value).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('ignores document movement and pointer up when no draw is active', async () => {
|
||||
const c = setup([box()])
|
||||
|
||||
c.onCanvasPointerMove(pe(5, 95))
|
||||
c.onDocPointerUp(pe(95, 95))
|
||||
await flush()
|
||||
|
||||
expect(c.modelValue.value).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('uses zero pointer coordinates when the canvas is unavailable', async () => {
|
||||
const c = setup()
|
||||
c.canvasEl.value = null
|
||||
|
||||
c.onPointerDown(pe(50, 50))
|
||||
c.onCanvasPointerMove(pe(80, 80))
|
||||
c.onDocPointerUp(pe(80, 80))
|
||||
await flush()
|
||||
|
||||
expect(c.modelValue.value).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('redraws active text regions with fallback palette color', async () => {
|
||||
const c = setup([
|
||||
box({
|
||||
x: 10,
|
||||
y: 10,
|
||||
width: 30,
|
||||
height: 30,
|
||||
metadata: {
|
||||
type: 'text',
|
||||
text: 'hello',
|
||||
desc: 'alpha beta\n\ncharlie',
|
||||
palette: []
|
||||
}
|
||||
})
|
||||
])
|
||||
|
||||
c.focused.value = true
|
||||
c.syncState()
|
||||
await flush()
|
||||
|
||||
expect(c.modelValue.value).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('draws safely when the canvas context is unavailable', async () => {
|
||||
const c = setup([box()])
|
||||
c.canvasEl.value = makeCanvas({ context: null })
|
||||
|
||||
c.syncState()
|
||||
await flush()
|
||||
|
||||
expect(c.modelValue.value).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useBoundingBoxes region editing', () => {
|
||||
@@ -346,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)
|
||||
})
|
||||
@@ -363,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()
|
||||
|
||||
@@ -443,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', () => {
|
||||
@@ -538,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,124 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mockSelectionState = vi.hoisted(() => ({
|
||||
refs: null as null | {
|
||||
hasMultipleSelection: { value: boolean }
|
||||
}
|
||||
}))
|
||||
|
||||
const mockSettingStore = vi.hoisted(() => ({
|
||||
get: vi.fn()
|
||||
}))
|
||||
|
||||
const mockTitleEditorStore = vi.hoisted(() => ({
|
||||
titleEditorTarget: null as null | object
|
||||
}))
|
||||
|
||||
const mockApp = vi.hoisted(() => ({
|
||||
canvas: {
|
||||
selectedItems: new Set<object>(),
|
||||
graph: {
|
||||
add: vi.fn()
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
const mockGroups = vi.hoisted(() => ({
|
||||
instances: [] as Array<{
|
||||
resizeTo: ReturnType<typeof vi.fn>
|
||||
}>
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/graph/useSelectionState', async () => {
|
||||
const { ref } = await import('vue')
|
||||
const hasMultipleSelection = ref(false)
|
||||
mockSelectionState.refs = {
|
||||
hasMultipleSelection
|
||||
}
|
||||
|
||||
return {
|
||||
useSelectionState: () => ({
|
||||
hasMultipleSelection
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => mockSettingStore
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useTitleEditorStore: () => mockTitleEditorStore
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: mockApp
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/litegraph/src/litegraph', () => ({
|
||||
LGraphGroup: class MockLGraphGroup {
|
||||
resizeTo = vi.fn()
|
||||
|
||||
constructor() {
|
||||
mockGroups.instances.push(this)
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
describe('useFrameNodes', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
if (mockSelectionState.refs) {
|
||||
mockSelectionState.refs.hasMultipleSelection.value = false
|
||||
}
|
||||
mockSettingStore.get.mockReturnValue(24)
|
||||
mockTitleEditorStore.titleEditorTarget = null
|
||||
mockApp.canvas.selectedItems = new Set()
|
||||
mockApp.canvas.graph = {
|
||||
add: vi.fn()
|
||||
}
|
||||
mockGroups.instances = []
|
||||
})
|
||||
|
||||
it('exposes whether selected nodes can be framed', async () => {
|
||||
const { useFrameNodes } = await import('./useFrameNodes')
|
||||
const { canFrame } = useFrameNodes()
|
||||
|
||||
expect(canFrame.value).toBe(false)
|
||||
|
||||
if (!mockSelectionState.refs) {
|
||||
throw new Error('selection refs were not initialized')
|
||||
}
|
||||
mockSelectionState.refs.hasMultipleSelection.value = true
|
||||
|
||||
expect(canFrame.value).toBe(true)
|
||||
})
|
||||
|
||||
it('does nothing when no items are selected', async () => {
|
||||
const { useFrameNodes } = await import('./useFrameNodes')
|
||||
const { frameNodes } = useFrameNodes()
|
||||
|
||||
frameNodes()
|
||||
|
||||
expect(mockGroups.instances).toHaveLength(0)
|
||||
expect(mockApp.canvas.graph.add).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('frames selected items and opens the title editor on the new group', async () => {
|
||||
const selectedNode = {}
|
||||
mockApp.canvas.selectedItems = new Set([selectedNode])
|
||||
|
||||
const { useFrameNodes } = await import('./useFrameNodes')
|
||||
const { frameNodes } = useFrameNodes()
|
||||
|
||||
frameNodes()
|
||||
|
||||
const group = mockGroups.instances[0]
|
||||
expect(group.resizeTo).toHaveBeenCalledWith(
|
||||
mockApp.canvas.selectedItems,
|
||||
24
|
||||
)
|
||||
expect(mockApp.canvas.graph.add).toHaveBeenCalledWith(group)
|
||||
expect(mockTitleEditorStore.titleEditorTarget).toBe(group)
|
||||
})
|
||||
})
|
||||
@@ -1,14 +1,9 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { 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 {
|
||||
@@ -19,10 +14,8 @@ import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { IS_CONTROL_WIDGET } from '@/scripts/widgets'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
describe('Node Reactivity', () => {
|
||||
beforeEach(() => {
|
||||
@@ -270,26 +263,6 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
|
||||
|
||||
expect(widgetData.slotMetadata).toBeUndefined()
|
||||
})
|
||||
|
||||
it('maps widget slot metadata even when the input slot name is empty', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
node.addWidget('string', 'prompt', 'hello', () => undefined, {})
|
||||
const input = node.addInput('', 'STRING')
|
||||
input.widget = { name: 'prompt' }
|
||||
graph.add(node)
|
||||
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const widgetData = vueNodeData
|
||||
.get(node.id)
|
||||
?.widgets?.find((w) => w.name === 'prompt')
|
||||
|
||||
expect(widgetData?.slotMetadata).toMatchObject({
|
||||
index: 0,
|
||||
linked: false,
|
||||
type: 'STRING'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Subgraph output slot label reactivity', () => {
|
||||
@@ -783,534 +756,3 @@ describe('Pre-remove vueNodeData drain', () => {
|
||||
).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Graph node manager property triggers', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
it('updates Vue node data for LiteGraph property change events', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
graph.add(node)
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
|
||||
graph.trigger('node:property:changed', {
|
||||
nodeId: node.id,
|
||||
property: 'title',
|
||||
newValue: 'Renamed'
|
||||
})
|
||||
graph.trigger('node:property:changed', {
|
||||
nodeId: node.id,
|
||||
property: 'has_errors',
|
||||
newValue: true
|
||||
})
|
||||
graph.trigger('node:property:changed', {
|
||||
nodeId: node.id,
|
||||
property: 'flags.collapsed',
|
||||
newValue: true
|
||||
})
|
||||
graph.trigger('node:property:changed', {
|
||||
nodeId: node.id,
|
||||
property: 'flags.ghost',
|
||||
newValue: true
|
||||
})
|
||||
graph.trigger('node:property:changed', {
|
||||
nodeId: node.id,
|
||||
property: 'flags.pinned',
|
||||
newValue: true
|
||||
})
|
||||
graph.trigger('node:property:changed', {
|
||||
nodeId: node.id,
|
||||
property: 'mode',
|
||||
newValue: 4
|
||||
})
|
||||
graph.trigger('node:property:changed', {
|
||||
nodeId: node.id,
|
||||
property: 'color',
|
||||
newValue: '#123456'
|
||||
})
|
||||
graph.trigger('node:property:changed', {
|
||||
nodeId: node.id,
|
||||
property: 'bgcolor',
|
||||
newValue: '#abcdef'
|
||||
})
|
||||
graph.trigger('node:property:changed', {
|
||||
nodeId: node.id,
|
||||
property: 'shape',
|
||||
newValue: 2
|
||||
})
|
||||
graph.trigger('node:property:changed', {
|
||||
nodeId: node.id,
|
||||
property: 'showAdvanced',
|
||||
newValue: true
|
||||
})
|
||||
graph.trigger('node:property:changed', {
|
||||
nodeId: node.id,
|
||||
property: 'badges',
|
||||
newValue: [{ text: 'hot' }]
|
||||
})
|
||||
|
||||
expect(vueNodeData.get(node.id)).toMatchObject({
|
||||
title: 'Renamed',
|
||||
hasErrors: true,
|
||||
flags: {
|
||||
collapsed: true,
|
||||
ghost: true,
|
||||
pinned: true
|
||||
},
|
||||
mode: 4,
|
||||
color: '#123456',
|
||||
bgcolor: '#abcdef',
|
||||
shape: 2,
|
||||
showAdvanced: true,
|
||||
badges: [{ text: 'hot' }]
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes invalid property payloads to safe Vue node data', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
graph.add(node)
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
|
||||
graph.trigger('node:property:changed', {
|
||||
nodeId: node.id,
|
||||
property: 'mode',
|
||||
newValue: 'invalid'
|
||||
})
|
||||
graph.trigger('node:property:changed', {
|
||||
nodeId: node.id,
|
||||
property: 'color',
|
||||
newValue: false
|
||||
})
|
||||
graph.trigger('node:property:changed', {
|
||||
nodeId: node.id,
|
||||
property: 'bgcolor',
|
||||
newValue: 123
|
||||
})
|
||||
graph.trigger('node:property:changed', {
|
||||
nodeId: node.id,
|
||||
property: 'shape',
|
||||
newValue: 'round'
|
||||
})
|
||||
|
||||
expect(vueNodeData.get(node.id)).toMatchObject({
|
||||
mode: 0,
|
||||
color: undefined,
|
||||
bgcolor: undefined,
|
||||
shape: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores property events for nodes the manager does not track', () => {
|
||||
const graph = new LGraph()
|
||||
useGraphNodeManager(graph)
|
||||
|
||||
expect(() =>
|
||||
graph.trigger('node:property:changed', {
|
||||
nodeId: 'missing',
|
||||
property: 'title',
|
||||
newValue: 'ignored'
|
||||
})
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it('ignores non-input slot link events and refreshes slot error metadata', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
node.addWidget('string', 'prompt', 'hello', () => undefined)
|
||||
const input = node.addInput('prompt', 'STRING')
|
||||
input.widget = { name: 'prompt' }
|
||||
graph.add(node)
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const widgetData = vueNodeData
|
||||
.get(node.id)
|
||||
?.widgets?.find((w) => w.name === 'prompt')
|
||||
|
||||
graph.trigger('node:slot-links:changed', {
|
||||
nodeId: node.id,
|
||||
slotType: NodeSlotType.OUTPUT
|
||||
})
|
||||
expect(widgetData?.slotMetadata?.linked).toBe(false)
|
||||
|
||||
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)
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
|
||||
const id = subgraphNode.inputs[0].widgetId
|
||||
if (!id) throw new Error('Expected promoted input to have widgetId')
|
||||
const widgetStore = useWidgetValueStore()
|
||||
if (widgetStore.getWidget(id)) {
|
||||
widgetStore.setValue(id, 'existing.safetensors')
|
||||
} else {
|
||||
widgetStore.registerWidget(id, {
|
||||
type: 'combo',
|
||||
value: 'existing.safetensors',
|
||||
options: {},
|
||||
label: 'Existing'
|
||||
})
|
||||
}
|
||||
|
||||
useGraphNodeManager(graph)
|
||||
|
||||
expect(widgetStore.getWidget(id)?.value).toBe('existing.safetensors')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Graph node manager lifecycle hooks', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
it('defers layout extraction until graph configuration completes', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
node.title = 'Before'
|
||||
const originalOnNodeAdded = vi.fn()
|
||||
const originalAfterConfigured = vi.fn()
|
||||
graph.onNodeAdded = originalOnNodeAdded
|
||||
node.onAfterGraphConfigured = originalAfterConfigured
|
||||
const originalWindowApp = window.app
|
||||
window.app = { configuringGraph: true } as Window['app']
|
||||
|
||||
try {
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
graph.add(node)
|
||||
|
||||
expect(originalOnNodeAdded).toHaveBeenCalledWith(node)
|
||||
expect(vueNodeData.get(node.id)?.title).toBe('Before')
|
||||
|
||||
node.title = 'After'
|
||||
node.onAfterGraphConfigured?.()
|
||||
|
||||
expect(originalAfterConfigured).toHaveBeenCalled()
|
||||
expect(vueNodeData.get(node.id)?.title).toBe('After')
|
||||
} finally {
|
||||
window.app = originalWindowApp
|
||||
}
|
||||
})
|
||||
|
||||
it('chains original remove and trigger handlers, then restores them on cleanup', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
const originalOnNodeAdded = vi.fn()
|
||||
const originalOnNodeRemoved = vi.fn()
|
||||
const originalOnTrigger = vi.fn()
|
||||
graph.onNodeAdded = originalOnNodeAdded
|
||||
graph.onNodeRemoved = originalOnNodeRemoved
|
||||
graph.onTrigger = originalOnTrigger
|
||||
|
||||
const manager = useGraphNodeManager(graph)
|
||||
graph.add(node)
|
||||
graph.trigger('node:property:changed', {
|
||||
nodeId: node.id,
|
||||
property: 'title',
|
||||
newValue: 'Renamed'
|
||||
})
|
||||
graph.remove(node)
|
||||
|
||||
expect(originalOnNodeAdded).toHaveBeenCalledWith(node)
|
||||
expect(originalOnTrigger).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'node:property:changed' })
|
||||
)
|
||||
expect(originalOnNodeRemoved).toHaveBeenCalledWith(node)
|
||||
expect(manager.vueNodeData.size).toBe(0)
|
||||
|
||||
manager.cleanup()
|
||||
|
||||
expect(graph.onNodeAdded).toBe(originalOnNodeAdded)
|
||||
expect(graph.onNodeRemoved).toBe(originalOnNodeRemoved)
|
||||
expect(graph.onTrigger).toBe(originalOnTrigger)
|
||||
})
|
||||
|
||||
it('cleans up to undefined when no original callbacks existed', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
graph.add(node)
|
||||
|
||||
const manager = useGraphNodeManager(graph)
|
||||
expect(manager.vueNodeData.has(node.id)).toBe(true)
|
||||
|
||||
manager.cleanup()
|
||||
|
||||
expect(graph.onNodeAdded).toBeUndefined()
|
||||
expect(graph.onNodeRemoved).toBeUndefined()
|
||||
expect(graph.onTrigger).toBeUndefined()
|
||||
expect(manager.vueNodeData.size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getControlWidget', () => {
|
||||
it('normalizes linked control widget values and updates the source widget', () => {
|
||||
const linkedControl = {
|
||||
[IS_CONTROL_WIDGET]: true,
|
||||
value: 'fixed'
|
||||
}
|
||||
const 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,223 +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)
|
||||
)
|
||||
})
|
||||
|
||||
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,55 +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()
|
||||
}))
|
||||
|
||||
function mockClipboard(clipboard: Partial<Clipboard> | undefined) {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: clipboard,
|
||||
@@ -58,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 {
|
||||
@@ -85,19 +45,14 @@ function createImageNode(
|
||||
}
|
||||
|
||||
describe('useImageMenuOptions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
@@ -106,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)
|
||||
|
||||
@@ -115,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)
|
||||
|
||||
@@ -141,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)
|
||||
|
||||
@@ -155,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')
|
||||
|
||||
@@ -164,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)
|
||||
@@ -186,7 +135,7 @@ describe('useImageMenuOptions', () => {
|
||||
})
|
||||
)
|
||||
|
||||
const { getImageMenuOptions } = setup()
|
||||
const { getImageMenuOptions } = useImageMenuOptions()
|
||||
const options = getImageMenuOptions(node)
|
||||
const pasteOption = options.find((o) => o.label === 'Paste Image')
|
||||
|
||||
@@ -203,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')
|
||||
|
||||
@@ -224,7 +173,7 @@ describe('useImageMenuOptions', () => {
|
||||
})
|
||||
)
|
||||
|
||||
const { getImageMenuOptions } = setup()
|
||||
const { getImageMenuOptions } = useImageMenuOptions()
|
||||
const options = getImageMenuOptions(node)
|
||||
const pasteOption = options.find((o) => o.label === 'Paste Image')
|
||||
|
||||
@@ -233,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,292 +0,0 @@
|
||||
import { ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LGraphGroup } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
isNodeOptionsOpen,
|
||||
registerNodeOptionsInstance,
|
||||
showNodeOptions,
|
||||
toggleNodeOptions,
|
||||
useMoreOptionsMenu
|
||||
} from '@/composables/graph/useMoreOptionsMenu'
|
||||
|
||||
const {
|
||||
canvasState,
|
||||
extraWidgetOptions,
|
||||
imageOptions,
|
||||
nodeMenu,
|
||||
selectionMenu,
|
||||
selectionState
|
||||
} = vi.hoisted(() => ({
|
||||
canvasState: {
|
||||
canvas: undefined as
|
||||
| undefined
|
||||
| {
|
||||
getNodeMenuOptions: ReturnType<typeof vi.fn>
|
||||
}
|
||||
},
|
||||
extraWidgetOptions: {
|
||||
value: [] as Array<{ content: string; callback?: () => void }>
|
||||
},
|
||||
imageOptions: {
|
||||
value: [] as Array<{ label: string; hasSubmenu?: boolean; submenu?: [] }>
|
||||
},
|
||||
nodeMenu: {
|
||||
visualOptions: {
|
||||
value: [] as Array<{
|
||||
label: string
|
||||
hasSubmenu?: boolean
|
||||
submenu?: Array<{ label: string; action: () => void }>
|
||||
}>
|
||||
}
|
||||
},
|
||||
selectionMenu: {
|
||||
basicOptions: { value: [{ label: 'Copy' }] },
|
||||
multipleOptions: { value: [{ label: 'Align' }] },
|
||||
subgraphOptions: { value: [] as Array<{ label: string }> }
|
||||
},
|
||||
selectionState: {
|
||||
selectedItems: { value: [] as unknown[] },
|
||||
selectedNodes: { value: [] as unknown[] },
|
||||
canOpenNodeInfo: { value: false },
|
||||
openNodeInfo: vi.fn(() => true),
|
||||
hasSubgraphs: { value: false },
|
||||
hasImageNode: { value: false },
|
||||
hasOutputNodesSelected: { value: false },
|
||||
hasMultipleSelection: { value: false },
|
||||
computeSelectionFlags: vi.fn(() => ({
|
||||
collapsed: false,
|
||||
pinned: false
|
||||
}))
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/graph/useSelectionState', () => ({
|
||||
useSelectionState: () => selectionState
|
||||
}))
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => canvasState
|
||||
}))
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
getExtraOptionsForWidget: () => extraWidgetOptions.value
|
||||
}))
|
||||
vi.mock('@/composables/graph/useImageMenuOptions', () => ({
|
||||
useImageMenuOptions: () => ({
|
||||
getImageMenuOptions: () => imageOptions.value
|
||||
})
|
||||
}))
|
||||
vi.mock('@/composables/graph/useNodeMenuOptions', () => ({
|
||||
useNodeMenuOptions: () => ({
|
||||
getNodeInfoOption: (openNodeInfo: () => boolean) => ({
|
||||
label: 'Node Info',
|
||||
action: openNodeInfo
|
||||
}),
|
||||
getNodeVisualOptions: () => nodeMenu.visualOptions.value,
|
||||
getPinOption: () => ({ label: 'Pin' }),
|
||||
getBypassOption: () => ({ label: 'Bypass' }),
|
||||
getRunBranchOption: () => ({ label: 'Run Branch' })
|
||||
})
|
||||
}))
|
||||
vi.mock('@/composables/graph/useGroupMenuOptions', () => ({
|
||||
useGroupMenuOptions: () => ({
|
||||
getFitGroupToNodesOption: () => ({ label: 'Fit' }),
|
||||
getGroupColorOptions: () => ({ label: 'Group Color' }),
|
||||
getGroupModeOptions: () => [{ label: 'Group Mode' }]
|
||||
})
|
||||
}))
|
||||
vi.mock('@/composables/graph/useSelectionMenuOptions', () => ({
|
||||
useSelectionMenuOptions: () => ({
|
||||
getBasicSelectionOptions: () => selectionMenu.basicOptions.value,
|
||||
getMultipleNodesOptions: () => selectionMenu.multipleOptions.value,
|
||||
getSubgraphOptions: () => selectionMenu.subgraphOptions.value
|
||||
})
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
registerNodeOptionsInstance(null)
|
||||
canvasState.canvas = undefined
|
||||
extraWidgetOptions.value = []
|
||||
imageOptions.value = []
|
||||
nodeMenu.visualOptions.value = []
|
||||
selectionMenu.basicOptions.value = [{ label: 'Copy' }]
|
||||
selectionMenu.multipleOptions.value = [{ label: 'Align' }]
|
||||
selectionMenu.subgraphOptions.value = []
|
||||
selectionState.selectedItems.value = []
|
||||
selectionState.selectedNodes.value = []
|
||||
selectionState.canOpenNodeInfo.value = false
|
||||
selectionState.hasSubgraphs.value = false
|
||||
selectionState.hasImageNode.value = false
|
||||
selectionState.hasOutputNodesSelected.value = false
|
||||
selectionState.hasMultipleSelection.value = false
|
||||
selectionState.computeSelectionFlags.mockReturnValue({
|
||||
collapsed: false,
|
||||
pinned: false
|
||||
})
|
||||
})
|
||||
|
||||
function labels() {
|
||||
return useMoreOptionsMenu()
|
||||
.menuOptions.value.map((o) => o.label)
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
describe('node options popover instance', () => {
|
||||
it('reports closed when no instance is registered', () => {
|
||||
expect(isNodeOptionsOpen()).toBe(false)
|
||||
})
|
||||
|
||||
it('reflects the registered instance open state and forwards toggle/show', () => {
|
||||
const toggle = vi.fn()
|
||||
const show = vi.fn()
|
||||
registerNodeOptionsInstance({
|
||||
toggle,
|
||||
show,
|
||||
hide: vi.fn(),
|
||||
isOpen: ref(true)
|
||||
})
|
||||
|
||||
expect(isNodeOptionsOpen()).toBe(true)
|
||||
toggleNodeOptions(new Event('click'))
|
||||
showNodeOptions(new MouseEvent('contextmenu'))
|
||||
expect(toggle).toHaveBeenCalled()
|
||||
expect(show).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useMoreOptionsMenu', () => {
|
||||
it('assembles a non-empty menu for a single selected node', () => {
|
||||
const node = { id: 1, widgets: [] }
|
||||
selectionState.selectedItems.value = [node]
|
||||
selectionState.selectedNodes.value = [node]
|
||||
|
||||
expect(labels()).toContain('Copy')
|
||||
expect(labels()).toContain('Pin')
|
||||
})
|
||||
|
||||
it('includes run-branch and multiple-node options for output selections', () => {
|
||||
const nodes = [
|
||||
{ id: 1, widgets: [] },
|
||||
{ id: 2, widgets: [] }
|
||||
]
|
||||
selectionState.selectedItems.value = nodes
|
||||
selectionState.selectedNodes.value = nodes
|
||||
selectionState.hasOutputNodesSelected.value = true
|
||||
selectionState.hasMultipleSelection.value = true
|
||||
|
||||
const menuLabels = labels()
|
||||
expect(menuLabels).toContain('Run Branch')
|
||||
expect(menuLabels).toContain('Align')
|
||||
})
|
||||
|
||||
it('recomputes menu flags after a manual bump', () => {
|
||||
const { bump, menuOptions } = useMoreOptionsMenu()
|
||||
void menuOptions.value
|
||||
expect(selectionState.computeSelectionFlags).toHaveBeenCalledTimes(1)
|
||||
|
||||
bump()
|
||||
void menuOptions.value
|
||||
expect(selectionState.computeSelectionFlags).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('assembles group-context options for a single selected group', () => {
|
||||
const group = new LGraphGroup('Group')
|
||||
selectionState.selectedItems.value = [group]
|
||||
selectionState.selectedNodes.value = []
|
||||
|
||||
const menuLabels = labels()
|
||||
expect(menuLabels).toContain('Group Mode')
|
||||
expect(menuLabels).toContain('Fit')
|
||||
expect(menuLabels).toContain('Group Color')
|
||||
})
|
||||
|
||||
it('includes node info and visual options for a single node', () => {
|
||||
const node = { id: 1, widgets: [] }
|
||||
selectionState.selectedItems.value = [node]
|
||||
selectionState.selectedNodes.value = [node]
|
||||
selectionState.canOpenNodeInfo.value = true
|
||||
nodeMenu.visualOptions.value = [
|
||||
{ label: 'Minimize Node' },
|
||||
{ label: 'Shape', hasSubmenu: true, submenu: [] },
|
||||
{ label: 'Color', hasSubmenu: true, submenu: [] }
|
||||
]
|
||||
|
||||
const menu = useMoreOptionsMenu().menuOptions.value
|
||||
expect(menu.map((o) => o.label)).toEqual(
|
||||
expect.arrayContaining(['Node Info', 'Minimize Node', 'Shape', 'Color'])
|
||||
)
|
||||
menu.find((o) => o.label === 'Node Info')?.action?.()
|
||||
expect(selectionState.openNodeInfo).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns only entries that have populated submenus', () => {
|
||||
const node = { id: 1, widgets: [] }
|
||||
selectionState.selectedItems.value = [node]
|
||||
selectionState.selectedNodes.value = [node]
|
||||
nodeMenu.visualOptions.value = [
|
||||
{ label: 'Minimize Node' },
|
||||
{
|
||||
label: 'Shape',
|
||||
hasSubmenu: true,
|
||||
submenu: [{ label: 'Box', action: vi.fn() }]
|
||||
},
|
||||
{ label: 'Color', hasSubmenu: true }
|
||||
]
|
||||
|
||||
expect(
|
||||
useMoreOptionsMenu().menuOptionsWithSubmenu.value.map((o) => o.label)
|
||||
).toEqual(['Shape'])
|
||||
})
|
||||
|
||||
it('includes image menu options for a selected image node', () => {
|
||||
const node = { id: 1, widgets: [] }
|
||||
selectionState.selectedItems.value = [node]
|
||||
selectionState.selectedNodes.value = [node]
|
||||
selectionState.hasImageNode.value = true
|
||||
imageOptions.value = [{ label: 'Open Image' }]
|
||||
|
||||
expect(labels()).toContain('Open Image')
|
||||
})
|
||||
|
||||
it('merges LiteGraph menu options for a single selected node', () => {
|
||||
const node = { id: 1, widgets: [] }
|
||||
const getNodeMenuOptions = vi.fn(() => [
|
||||
{ content: 'Extension Action', callback: vi.fn() }
|
||||
])
|
||||
selectionState.selectedItems.value = [node]
|
||||
selectionState.selectedNodes.value = [node]
|
||||
canvasState.canvas = { getNodeMenuOptions }
|
||||
|
||||
expect(labels()).toContain('Extension Action')
|
||||
expect(getNodeMenuOptions).toHaveBeenCalledWith(node)
|
||||
})
|
||||
|
||||
it('keeps Vue options when LiteGraph menu construction throws', () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const node = { id: 1, widgets: [] }
|
||||
selectionState.selectedItems.value = [node]
|
||||
selectionState.selectedNodes.value = [node]
|
||||
canvasState.canvas = {
|
||||
getNodeMenuOptions: vi.fn(() => {
|
||||
throw new Error('boom')
|
||||
})
|
||||
}
|
||||
|
||||
expect(labels()).toContain('Copy')
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
'Error getting LiteGraph menu items:',
|
||||
expect.any(Error)
|
||||
)
|
||||
})
|
||||
|
||||
it('adds hovered widget options to the selected node menu', () => {
|
||||
const node = { id: 1, widgets: [{ name: 'image' }] }
|
||||
selectionState.selectedItems.value = [node]
|
||||
selectionState.selectedNodes.value = [node]
|
||||
extraWidgetOptions.value = [{ content: 'Widget Extra', callback: vi.fn() }]
|
||||
|
||||
showNodeOptions(new MouseEvent('contextmenu'), 'image')
|
||||
|
||||
expect(labels()).toContain('Widget Extra')
|
||||
})
|
||||
})
|
||||
@@ -1,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,9 +42,6 @@ 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'
|
||||
|
||||
@@ -55,128 +52,8 @@ function setup() {
|
||||
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
|
||||
@@ -185,28 +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()
|
||||
})
|
||||
|
||||
describe('initial reactive state', () => {
|
||||
@@ -271,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', () => {
|
||||
@@ -342,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', () => {
|
||||
@@ -362,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', () => {
|
||||
@@ -410,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 { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { 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
|
||||
})
|
||||
|
||||
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(
|
||||
fromPartial<Response>({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
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(
|
||||
fromPartial<Response>({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({ 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,87 +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 graphNode = node([])
|
||||
const { showTextPreview } = useNodeProgressText()
|
||||
|
||||
showTextPreview(graphNode, 'queued')
|
||||
|
||||
expect(mockTextPreviewWidget).toHaveBeenCalledWith(graphNode, {
|
||||
name: '$$node-text-preview',
|
||||
type: 'progressText'
|
||||
})
|
||||
expect(mockTextPreviewWidget.mock.results[0].value.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,12 +22,7 @@ function setup(initial: string[]) {
|
||||
return { modelValue, container, picker, ...api }
|
||||
}
|
||||
|
||||
const mouseEvent = () => fromPartial<MouseEvent>({ stopPropagation: vi.fn() })
|
||||
|
||||
function makeInputEvent(value: string): Event {
|
||||
const event: unknown = { target: { value } }
|
||||
return event as Event
|
||||
}
|
||||
const mouseEvent = () => ({ stopPropagation: vi.fn() }) as unknown as MouseEvent
|
||||
|
||||
describe('usePaletteSwatchRow', () => {
|
||||
it('appends a default color', () => {
|
||||
@@ -61,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'])
|
||||
})
|
||||
|
||||
@@ -123,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,132 +0,0 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { createApp, h, ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useJobActions } from '@/composables/queue/useJobActions'
|
||||
import type { JobListItem } from '@/composables/queue/useJobList'
|
||||
import type { TaskItemImpl } from '@/stores/queueStore'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
const { cancelJob, removeFailedJob, wrapWithErrorHandlingAsync } = vi.hoisted(
|
||||
() => ({
|
||||
cancelJob: vi.fn(),
|
||||
removeFailedJob: vi.fn(),
|
||||
wrapWithErrorHandlingAsync: vi.fn(
|
||||
<T extends (...args: never[]) => Promise<unknown>>(fn: T) => fn
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/composables/useErrorHandling', () => ({
|
||||
useErrorHandling: () => ({ wrapWithErrorHandlingAsync })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/queue/useJobMenu', () => ({
|
||||
useJobMenu: () => ({ cancelJob, removeFailedJob })
|
||||
}))
|
||||
|
||||
function mountJobActions(job: Ref<JobListItem | null | undefined>) {
|
||||
let result: ReturnType<typeof useJobActions> | undefined
|
||||
const app = createApp({
|
||||
setup() {
|
||||
result = useJobActions(job)
|
||||
return () => h('div')
|
||||
}
|
||||
})
|
||||
app.use(
|
||||
createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: {} }
|
||||
})
|
||||
)
|
||||
app.mount(document.createElement('div'))
|
||||
if (!result) throw new Error('useJobActions did not initialize')
|
||||
return {
|
||||
result,
|
||||
unmount: () => app.unmount()
|
||||
}
|
||||
}
|
||||
|
||||
function job(overrides: Partial<JobListItem> = {}): JobListItem {
|
||||
return {
|
||||
id: 'job-1',
|
||||
title: 'Job 1',
|
||||
meta: '',
|
||||
state: 'pending',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
cancelJob.mockReset().mockResolvedValue(undefined)
|
||||
removeFailedJob.mockReset().mockResolvedValue(undefined)
|
||||
wrapWithErrorHandlingAsync.mockClear()
|
||||
})
|
||||
|
||||
describe('useJobActions', () => {
|
||||
it('exposes localized action metadata', () => {
|
||||
const { result, unmount } = mountJobActions(ref(job()))
|
||||
|
||||
expect(result.cancelAction).toMatchObject({
|
||||
icon: 'icon-[lucide--x]',
|
||||
label: 'sideToolbar.queueProgressOverlay.cancelJobTooltip',
|
||||
variant: 'destructive'
|
||||
})
|
||||
expect(result.deleteAction).toMatchObject({
|
||||
icon: 'icon-[lucide--circle-minus]',
|
||||
label: 'queue.jobMenu.removeJob',
|
||||
variant: 'destructive'
|
||||
})
|
||||
expect(wrapWithErrorHandlingAsync).toHaveBeenCalledTimes(2)
|
||||
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('cancels active jobs unless clearing is hidden', async () => {
|
||||
const currentJob = ref(job({ state: 'running' }))
|
||||
const { result, unmount } = mountJobActions(currentJob)
|
||||
|
||||
expect(result.canCancelJob.value).toBe(true)
|
||||
await result.runCancelJob()
|
||||
expect(cancelJob).toHaveBeenCalledWith(currentJob.value)
|
||||
|
||||
currentJob.value = job({ state: 'pending', showClear: false })
|
||||
expect(result.canCancelJob.value).toBe(false)
|
||||
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('ignores cancel and delete requests without a current job or task', async () => {
|
||||
const currentJob = ref<JobListItem | null>(null)
|
||||
const { result, unmount } = mountJobActions(currentJob)
|
||||
|
||||
expect(result.canCancelJob.value).toBe(false)
|
||||
expect(result.canDeleteJob.value).toBe(false)
|
||||
await result.runCancelJob()
|
||||
await result.runDeleteJob()
|
||||
|
||||
currentJob.value = job({ state: 'failed' })
|
||||
await result.runDeleteJob()
|
||||
|
||||
expect(cancelJob).not.toHaveBeenCalled()
|
||||
expect(removeFailedJob).not.toHaveBeenCalled()
|
||||
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('removes failed jobs through their queue task', async () => {
|
||||
const task = fromPartial<TaskItemImpl>({ job: { id: 'prompt-1' } })
|
||||
const { result, unmount } = mountJobActions(
|
||||
ref(job({ state: 'failed', taskRef: task }))
|
||||
)
|
||||
|
||||
expect(result.canDeleteJob.value).toBe(true)
|
||||
await result.runDeleteJob()
|
||||
|
||||
expect(removeFailedJob).toHaveBeenCalledWith(task)
|
||||
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
@@ -280,20 +280,6 @@ describe('useJobMenu', () => {
|
||||
expect(copyToClipboardMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses an empty menu with the default current item getter', async () => {
|
||||
const { jobMenuEntries, openJobWorkflow, copyJobId, cancelJob } =
|
||||
useJobMenu()
|
||||
|
||||
await openJobWorkflow()
|
||||
await copyJobId()
|
||||
await cancelJob()
|
||||
|
||||
expect(jobMenuEntries.value).toEqual([])
|
||||
expect(getJobWorkflowMock).not.toHaveBeenCalled()
|
||||
expect(copyToClipboardMock).not.toHaveBeenCalled()
|
||||
expect(queueStoreMock.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.for([['running'], ['initialization'], ['pending']])(
|
||||
'cancels %s job via the state-agnostic jobs-namespace endpoint',
|
||||
async ([state]) => {
|
||||
@@ -407,26 +393,6 @@ describe('useJobMenu', () => {
|
||||
expect(optionsArg).toEqual({ reportType: 'queueJobError' })
|
||||
})
|
||||
|
||||
it('ignores failed report action when item disappears before click', async () => {
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
state: 'failed',
|
||||
taskRef: {
|
||||
errorMessage: 'Job failed with error'
|
||||
} as Partial<TaskItemImpl>
|
||||
})
|
||||
)
|
||||
|
||||
await nextTick()
|
||||
const entry = findActionEntry(jobMenuEntries.value, 'report-error')
|
||||
setCurrentItem(null)
|
||||
await entry?.onClick?.()
|
||||
|
||||
expect(dialogServiceMock.showExecutionErrorDialog).not.toHaveBeenCalled()
|
||||
expect(dialogServiceMock.showErrorDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores error actions when message missing', async () => {
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(
|
||||
@@ -578,74 +544,6 @@ describe('useJobMenu', () => {
|
||||
expect(createAnnotatedPathMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses no root folder when preview type is not an API result type', async () => {
|
||||
const node = {
|
||||
widgets: [{ name: 'image', value: null, callback: vi.fn() }],
|
||||
graph: { setDirtyCanvas: vi.fn() }
|
||||
}
|
||||
litegraphServiceMock.addNodeOnGraph.mockReturnValueOnce(node)
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
state: 'completed',
|
||||
taskRef: {
|
||||
previewOutput: {
|
||||
isImage: true,
|
||||
filename: 'foo.png',
|
||||
subfolder: 'bar',
|
||||
type: 'archive',
|
||||
url: 'http://asset'
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
await nextTick()
|
||||
const entry = findActionEntry(jobMenuEntries.value, 'add-to-current')
|
||||
await entry?.onClick?.()
|
||||
|
||||
expect(createAnnotatedPathMock).toHaveBeenCalledWith(
|
||||
{
|
||||
filename: 'foo.png',
|
||||
subfolder: 'bar',
|
||||
type: undefined
|
||||
},
|
||||
{ rootFolder: undefined },
|
||||
undefined
|
||||
)
|
||||
expect(node.graph.setDirtyCanvas).toHaveBeenCalledWith(true, true)
|
||||
})
|
||||
|
||||
it('marks graph dirty when created loader node has no matching widget', async () => {
|
||||
const node = {
|
||||
widgets: [{ name: 'other', value: null, callback: vi.fn() }],
|
||||
graph: { setDirtyCanvas: vi.fn() }
|
||||
}
|
||||
litegraphServiceMock.addNodeOnGraph.mockReturnValueOnce(node)
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
state: 'completed',
|
||||
taskRef: {
|
||||
previewOutput: {
|
||||
isImage: true,
|
||||
filename: 'foo.png',
|
||||
subfolder: '',
|
||||
type: 'output'
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
await nextTick()
|
||||
const entry = findActionEntry(jobMenuEntries.value, 'add-to-current')
|
||||
await entry?.onClick?.()
|
||||
|
||||
expect(node.widgets[0].value).toBeNull()
|
||||
expect(node.widgets[0].callback).not.toHaveBeenCalled()
|
||||
expect(node.graph.setDirtyCanvas).toHaveBeenCalledWith(true, true)
|
||||
})
|
||||
|
||||
it('ignores add-to-current entry when preview missing entirely', async () => {
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(
|
||||
@@ -696,45 +594,6 @@ describe('useJobMenu', () => {
|
||||
expect(downloadFileMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores completed asset actions when item disappears before click', async () => {
|
||||
const inspectSpy = vi.fn()
|
||||
const { jobMenuEntries } = mountJobMenu(inspectSpy)
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
state: 'completed',
|
||||
taskRef: {
|
||||
previewOutput: {
|
||||
isImage: true,
|
||||
filename: 'foo.png',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
url: 'https://asset'
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
await nextTick()
|
||||
const inspectEntry = findActionEntry(jobMenuEntries.value, 'inspect-asset')
|
||||
const addEntry = findActionEntry(jobMenuEntries.value, 'add-to-current')
|
||||
const downloadEntry = findActionEntry(jobMenuEntries.value, 'download')
|
||||
const exportEntry = findActionEntry(jobMenuEntries.value, 'export-workflow')
|
||||
const deleteEntry = findActionEntry(jobMenuEntries.value, 'delete')
|
||||
setCurrentItem(null)
|
||||
|
||||
void inspectEntry?.onClick?.()
|
||||
await addEntry?.onClick?.()
|
||||
void downloadEntry?.onClick?.()
|
||||
await exportEntry?.onClick?.()
|
||||
await deleteEntry?.onClick?.()
|
||||
|
||||
expect(inspectSpy).not.toHaveBeenCalled()
|
||||
expect(litegraphServiceMock.addNodeOnGraph).not.toHaveBeenCalled()
|
||||
expect(downloadFileMock).not.toHaveBeenCalled()
|
||||
expect(getJobWorkflowMock).not.toHaveBeenCalled()
|
||||
expect(mediaAssetActionsMock.deleteAssets).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('exports workflow with default filename when prompting disabled', async () => {
|
||||
const workflow = { foo: 'bar' }
|
||||
getJobWorkflowMock.mockResolvedValue(workflow)
|
||||
@@ -759,17 +618,6 @@ describe('useJobMenu', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('does not export workflow when workflow data is unavailable', async () => {
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(createJobItem({ state: 'completed' }))
|
||||
|
||||
await nextTick()
|
||||
const entry = findActionEntry(jobMenuEntries.value, 'export-workflow')
|
||||
await entry?.onClick?.()
|
||||
|
||||
expect(downloadBlobMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('prompts for filename when setting enabled', async () => {
|
||||
settingStoreMock.get.mockReturnValue(true)
|
||||
dialogServiceMock.prompt.mockResolvedValue('custom-name')
|
||||
@@ -865,24 +713,6 @@ describe('useJobMenu', () => {
|
||||
expect(queueStoreMock.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not delete asset when preview disappears before click', async () => {
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
state: 'completed',
|
||||
taskRef: { previewOutput: {} }
|
||||
})
|
||||
)
|
||||
|
||||
await nextTick()
|
||||
const entry = findActionEntry(jobMenuEntries.value, 'delete')
|
||||
setCurrentItem(createJobItem({ state: 'completed', taskRef: {} }))
|
||||
await entry?.onClick?.()
|
||||
|
||||
expect(mediaAssetActionsMock.deleteAssets).not.toHaveBeenCalled()
|
||||
expect(queueStoreMock.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('removes failed job via menu entry', async () => {
|
||||
const taskRef = { id: 'task-1' }
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
|
||||
@@ -79,10 +79,12 @@ describe(useQueueNotificationBanners, () => {
|
||||
isImage?: boolean
|
||||
} = {}
|
||||
): MockTask => {
|
||||
const { state = 'Completed', previewUrl, isImage = true } = options
|
||||
// Only default the timestamp when the caller omitted the key, so an
|
||||
// explicit `ts: undefined` really produces a task without a timestamp.
|
||||
const ts = 'ts' in options ? options.ts : Date.now()
|
||||
const {
|
||||
state = 'Completed',
|
||||
ts = Date.now(),
|
||||
previewUrl,
|
||||
isImage = true
|
||||
} = options
|
||||
|
||||
const task: MockTask = {
|
||||
displayStatus: state,
|
||||
@@ -184,75 +186,6 @@ describe(useQueueNotificationBanners, () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('converts a queued-pending notification waiting behind the active one', async () => {
|
||||
const { unmount, composable } = mountComposable()
|
||||
|
||||
try {
|
||||
mockApi.dispatchEvent(
|
||||
new CustomEvent('promptQueued', {
|
||||
detail: { requestId: 1, batchCount: 1 }
|
||||
})
|
||||
)
|
||||
await nextTick()
|
||||
|
||||
mockApi.dispatchEvent(
|
||||
new CustomEvent('promptQueueing', {
|
||||
detail: { requestId: 2, batchCount: 3 }
|
||||
})
|
||||
)
|
||||
mockApi.dispatchEvent(
|
||||
new CustomEvent('promptQueued', {
|
||||
detail: { requestId: 2, batchCount: 5 }
|
||||
})
|
||||
)
|
||||
await nextTick()
|
||||
|
||||
expect(composable.currentNotification.value).toEqual({
|
||||
type: 'queued',
|
||||
count: 1,
|
||||
requestId: 1
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(4000)
|
||||
await nextTick()
|
||||
|
||||
expect(composable.currentNotification.value).toEqual({
|
||||
type: 'queued',
|
||||
count: 5,
|
||||
requestId: 2
|
||||
})
|
||||
} finally {
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('converts queued-pending notifications without request ids', async () => {
|
||||
const { unmount, composable } = mountComposable()
|
||||
|
||||
try {
|
||||
mockApi.dispatchEvent(
|
||||
new CustomEvent('promptQueueing', {
|
||||
detail: { batchCount: 2 }
|
||||
})
|
||||
)
|
||||
await nextTick()
|
||||
|
||||
mockApi.dispatchEvent(
|
||||
new CustomEvent('promptQueued', {
|
||||
detail: { batchCount: 3 }
|
||||
})
|
||||
)
|
||||
await nextTick()
|
||||
|
||||
expect(composable.currentNotification.value).toEqual({
|
||||
type: 'queued',
|
||||
count: 3
|
||||
})
|
||||
} finally {
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('falls back to 1 when queued batch count is invalid', async () => {
|
||||
const { unmount, composable } = mountComposable()
|
||||
|
||||
@@ -373,64 +306,6 @@ describe(useQueueNotificationBanners, () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('shows failed notifications for failed-only batches', async () => {
|
||||
const { unmount, composable } = mountComposable()
|
||||
|
||||
try {
|
||||
await runBatch({
|
||||
start: 5_000,
|
||||
finish: 5_200,
|
||||
tasks: [createTask({ state: 'Failed', ts: 5_050 })]
|
||||
})
|
||||
|
||||
expect(composable.currentNotification.value).toEqual({
|
||||
type: 'failed',
|
||||
count: 1
|
||||
})
|
||||
} finally {
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not notify for old or unfinished history entries', async () => {
|
||||
const { unmount, composable } = mountComposable()
|
||||
|
||||
try {
|
||||
await runBatch({
|
||||
start: 6_000,
|
||||
finish: 6_200,
|
||||
tasks: [
|
||||
createTask({ ts: 5_999 }),
|
||||
createTask({ state: 'Running', ts: 6_050 }),
|
||||
createTask({ state: 'Pending', ts: undefined })
|
||||
]
|
||||
})
|
||||
|
||||
expect(composable.currentNotification.value).toBeNull()
|
||||
} finally {
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps no notification visible when an idle window has no finished tasks', async () => {
|
||||
const { unmount, composable } = mountComposable()
|
||||
|
||||
try {
|
||||
vi.setSystemTime(7_000)
|
||||
executionStore().isIdle = false
|
||||
await nextTick()
|
||||
|
||||
vi.setSystemTime(7_100)
|
||||
executionStore().isIdle = true
|
||||
queueStore().historyTasks = []
|
||||
await nextTick()
|
||||
|
||||
expect(composable.currentNotification.value).toBeNull()
|
||||
} finally {
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('uses up to two completion thumbnails for notification icon previews', async () => {
|
||||
const { unmount, composable } = mountComposable()
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -1,285 +0,0 @@
|
||||
import { ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useCanvasDrop } from '@/composables/useCanvasDrop'
|
||||
|
||||
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()
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/composables/element/useCanvasPositionConversion', () => ({
|
||||
useSharedCanvasPositionConversion: () => ({
|
||||
clientPosToCanvasPos: ([x, y]: [number, number]) => [x / 2, y / 2]
|
||||
})
|
||||
}))
|
||||
|
||||
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: [10, 44]
|
||||
})
|
||||
})
|
||||
|
||||
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: [10, 20]
|
||||
})
|
||||
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: [10, 20]
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,250 +0,0 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { IContextMenuOptions } from '@/lib/litegraph/src/interfaces'
|
||||
|
||||
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())
|
||||
|
||||
const mockClasses = vi.hoisted(() => {
|
||||
class LGraphCanvas {
|
||||
getCanvasMenuOptions() {
|
||||
return [{ content: 'Base' }, null]
|
||||
}
|
||||
|
||||
getNodeMenuOptions(node: unknown) {
|
||||
return [{ content: `Node:${String(node)}` }]
|
||||
}
|
||||
}
|
||||
|
||||
class ContextMenu {
|
||||
constructor(values: unknown, options: unknown) {
|
||||
mockContextMenuConstructor(values, options)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
LGraphCanvas,
|
||||
ContextMenu,
|
||||
LiteGraph: {
|
||||
ContextMenu
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/lib/litegraph/src/contextMenuCompat', () => ({
|
||||
legacyMenuCompat: {
|
||||
install: mockInstall,
|
||||
registerWrapper: mockRegisterWrapper,
|
||||
extractLegacyItems: mockExtractLegacyItems
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/litegraph/src/litegraph', () => ({
|
||||
LGraphCanvas: mockClasses.LGraphCanvas,
|
||||
LiteGraph: mockClasses.LiteGraph
|
||||
}))
|
||||
|
||||
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 { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { useContextMenuTranslation } from './useContextMenuTranslation'
|
||||
|
||||
describe('useContextMenuTranslation', () => {
|
||||
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'
|
||||
)
|
||||
mockClasses.LGraphCanvas.prototype.getCanvasMenuOptions = function () {
|
||||
return [{ content: 'Base' }, null]
|
||||
}
|
||||
mockClasses.LGraphCanvas.prototype.getNodeMenuOptions = function (
|
||||
node: unknown
|
||||
) {
|
||||
return [{ content: `Node:${String(node)}` }]
|
||||
}
|
||||
mockClasses.LiteGraph.ContextMenu = mockClasses.ContextMenu
|
||||
})
|
||||
|
||||
it('wraps canvas menu options with new API, legacy, and translated items', () => {
|
||||
useContextMenuTranslation()
|
||||
const canvas = new mockClasses.LGraphCanvas()
|
||||
|
||||
const result = canvas.getCanvasMenuOptions()
|
||||
|
||||
expect(mockInstall).toHaveBeenCalledWith(
|
||||
mockClasses.LGraphCanvas.prototype,
|
||||
'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 = new mockClasses.LGraphCanvas()
|
||||
|
||||
const result = canvas.getNodeMenuOptions('node')
|
||||
|
||||
expect(mockInstall).toHaveBeenCalledWith(
|
||||
mockClasses.LGraphCanvas.prototype,
|
||||
'getNodeMenuOptions'
|
||||
)
|
||||
expect(mockCollectNodeMenuItems).toHaveBeenCalledWith('node')
|
||||
expect(mockExtractLegacyItems).toHaveBeenCalledWith(
|
||||
'getNodeMenuOptions',
|
||||
canvas,
|
||||
'node'
|
||||
)
|
||||
expect(result).toEqual([
|
||||
{ content: 'Node: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,592 +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 { CHANNEL_MODES, 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('exposes all channel modes', () => {
|
||||
expect(CHANNEL_MODES).toEqual(['rgb', 'r', 'g', 'b', 'a', 'luminance'])
|
||||
})
|
||||
|
||||
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,189 +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()
|
||||
})
|
||||
})
|
||||
@@ -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,135 +0,0 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { createApp, h, ref } from 'vue'
|
||||
import { 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[] = []
|
||||
|
||||
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
|
||||
})
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -16,14 +16,12 @@ import {
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
|
||||
import {
|
||||
appendQuarantine,
|
||||
flushProxyWidgetMigration,
|
||||
normalizeLegacyProxyWidgetEntry,
|
||||
readHostQuarantine
|
||||
} from '@/core/graph/subgraph/migration/proxyWidgetMigration'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
import { toLinkId } from '@/types/linkId'
|
||||
import { UNASSIGNED_NODE_ID, toNodeId } from '@/types/nodeId'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
@@ -181,33 +179,6 @@ describe('flushProxyWidgetMigration', () => {
|
||||
expect(getPromotedInputValue(outerHost, 'text')).toBe('22222222222')
|
||||
})
|
||||
|
||||
it('createSubgraphInput: resolves a nested promoted input by host input name', () => {
|
||||
const rootGraph = new LGraph()
|
||||
const innerSubgraph = createTestSubgraph({ rootGraph })
|
||||
const source = new LGraphNode('CLIPTextEncode')
|
||||
const sourceSlot = source.addInput('text', 'STRING')
|
||||
sourceSlot.widget = { name: 'text' }
|
||||
source.addWidget('text', 'text', 'nested value', () => {})
|
||||
innerSubgraph.add(source)
|
||||
|
||||
const nestedHost = createTestSubgraphNode(innerSubgraph, {
|
||||
parentGraph: rootGraph
|
||||
})
|
||||
nestedHost.properties.proxyWidgets = [[String(source.id), 'text']]
|
||||
flushProxyWidgetMigration({ hostNode: nestedHost })
|
||||
|
||||
const outerSubgraph = createTestSubgraph({ rootGraph })
|
||||
outerSubgraph.add(nestedHost)
|
||||
const outerHost = createTestSubgraphNode(outerSubgraph, {
|
||||
parentGraph: rootGraph
|
||||
})
|
||||
outerHost.properties.proxyWidgets = [[String(nestedHost.id), 'text']]
|
||||
|
||||
flushProxyWidgetMigration({ hostNode: outerHost })
|
||||
|
||||
expect(getPromotedInputValue(outerHost, 'text')).toBe('nested value')
|
||||
})
|
||||
|
||||
it('alreadyLinked: leaves widget value unchanged when host value is a sparse hole', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'seed', type: 'INT' }]
|
||||
@@ -269,41 +240,6 @@ describe('flushProxyWidgetMigration', () => {
|
||||
).toBe('renamed_from_sidepanel')
|
||||
})
|
||||
|
||||
it('createSubgraphInput: falls back to the source widget type when the slot type is missing', () => {
|
||||
const host = buildHost()
|
||||
const inner = addInnerNode(host, 'Inner', (n) => {
|
||||
const slot = n.addInput('seed', 'INT')
|
||||
slot.type = undefined as never
|
||||
slot.widget = { name: 'seed' }
|
||||
n.addWidget('number', 'seed', 0, () => {})
|
||||
})
|
||||
|
||||
host.properties.proxyWidgets = [[String(inner.id), 'seed']]
|
||||
flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
expect(
|
||||
host.subgraph.inputs.find((input) => input.name === 'seed')?.type
|
||||
).toBe('number')
|
||||
})
|
||||
|
||||
it('createSubgraphInput: falls back to wildcard type when slot and widget type are missing', () => {
|
||||
const host = buildHost()
|
||||
const inner = addInnerNode(host, 'Inner', (n) => {
|
||||
const slot = n.addInput('seed', 'INT')
|
||||
slot.type = undefined as never
|
||||
slot.widget = { name: 'seed' }
|
||||
const widget = n.addWidget('number', 'seed', 0, () => {})
|
||||
widget.type = undefined as never
|
||||
})
|
||||
|
||||
host.properties.proxyWidgets = [[String(inner.id), 'seed']]
|
||||
flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
expect(
|
||||
host.subgraph.inputs.find((input) => input.name === 'seed')?.type
|
||||
).toBe('*')
|
||||
})
|
||||
|
||||
it('createSubgraphInput: quarantines missingSubgraphInput when source widget has no backing input slot', () => {
|
||||
const host = buildHost()
|
||||
const inner = addInnerNode(host, 'Inner', (n) => {
|
||||
@@ -392,88 +328,6 @@ describe('flushProxyWidgetMigration', () => {
|
||||
expect(getPromotedInputValue(host, 'value')).toBe(11)
|
||||
})
|
||||
|
||||
it('uses the primitive title as the promoted input name when it was renamed', () => {
|
||||
const host = buildHost()
|
||||
const { primitive } = addPrimitiveWithTargets(host, {
|
||||
targetCount: 1
|
||||
})
|
||||
primitive.title = 'Batch Size'
|
||||
|
||||
host.properties.proxyWidgets = [[String(primitive.id), 'value']]
|
||||
flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
expect(
|
||||
host.inputs.find((input) => input.name === 'Batch Size')
|
||||
).toBeDefined()
|
||||
})
|
||||
|
||||
it('skips a stale primitive bypass marker when the host input is absent', () => {
|
||||
const host = buildHost()
|
||||
const { primitive, targets } = addPrimitiveWithTargets(host, {
|
||||
targetCount: 1
|
||||
})
|
||||
primitive.properties = {
|
||||
proxyBypassedToSubgraphInput: 'deleted_input'
|
||||
}
|
||||
|
||||
host.properties.proxyWidgets = [[String(primitive.id), 'value']]
|
||||
flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
const slot = targets[0].inputs[0]
|
||||
const link = host.subgraph.links.get(slot.link!)
|
||||
expect(link?.origin_id).not.toBe(primitive.id)
|
||||
expect(host.inputs.find((input) => input.name === 'value')).toBeDefined()
|
||||
})
|
||||
|
||||
it('quarantines a stale primitive bypass marker that points to a plain input', () => {
|
||||
const host = buildHost()
|
||||
const { primitive } = addPrimitiveWithTargets(host, {
|
||||
targetCount: 1
|
||||
})
|
||||
primitive.properties = {
|
||||
proxyBypassedToSubgraphInput: 'plain'
|
||||
}
|
||||
host.addInput('plain', 'INT')
|
||||
|
||||
host.properties.proxyWidgets = [[String(primitive.id), 'value']]
|
||||
flushProxyWidgetMigration({
|
||||
hostNode: host,
|
||||
hostWidgetValues: [12]
|
||||
})
|
||||
|
||||
expect(readHostQuarantine(host)).toEqual([
|
||||
expect.objectContaining({
|
||||
originalEntry: [String(primitive.id), 'value'],
|
||||
reason: 'missingSubgraphInput'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('quarantines a stale primitive bypass marker that matches ambiguous host inputs', () => {
|
||||
const host = buildHost()
|
||||
const { primitive } = addPrimitiveWithTargets(host, {
|
||||
targetCount: 1
|
||||
})
|
||||
primitive.properties = {
|
||||
proxyBypassedToSubgraphInput: 'plain'
|
||||
}
|
||||
host.addInput('plain', 'INT')
|
||||
host.addInput('plain', 'INT')
|
||||
|
||||
host.properties.proxyWidgets = [[String(primitive.id), 'value']]
|
||||
flushProxyWidgetMigration({
|
||||
hostNode: host,
|
||||
hostWidgetValues: [12]
|
||||
})
|
||||
|
||||
expect(readHostQuarantine(host)).toEqual([
|
||||
expect.objectContaining({
|
||||
originalEntry: [String(primitive.id), 'value'],
|
||||
reason: 'ambiguousSubgraphInput'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('quarantines an unlinked primitive node with no fan-out', () => {
|
||||
const host = buildHost()
|
||||
const primitive = new LGraphNode('Primitive')
|
||||
@@ -492,64 +346,6 @@ describe('flushProxyWidgetMigration', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('quarantines primitive cohorts that disagree on source widget name', () => {
|
||||
const host = buildHost()
|
||||
const { primitive } = addPrimitiveWithTargets(host, {
|
||||
targetCount: 1
|
||||
})
|
||||
|
||||
host.properties.proxyWidgets = [
|
||||
[String(primitive.id), 'value'],
|
||||
[String(primitive.id), 'other']
|
||||
]
|
||||
flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
expect(readHostQuarantine(host)).toEqual([
|
||||
expect.objectContaining({
|
||||
originalEntry: [String(primitive.id), 'value'],
|
||||
reason: 'primitiveBypassFailed'
|
||||
}),
|
||||
expect.objectContaining({
|
||||
originalEntry: [String(primitive.id), 'other'],
|
||||
reason: 'primitiveBypassFailed'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('quarantines duplicate primitive entries with no fan-out targets', () => {
|
||||
const host = buildHost()
|
||||
const primitive = new LGraphNode('PrimitiveNode')
|
||||
primitive.type = 'PrimitiveNode'
|
||||
primitive.addOutput('value', 'INT')
|
||||
host.subgraph.add(primitive)
|
||||
|
||||
host.properties.proxyWidgets = [
|
||||
[String(primitive.id), 'value'],
|
||||
[String(primitive.id), 'value']
|
||||
]
|
||||
flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
expect(readHostQuarantine(host)).toEqual([
|
||||
expect.objectContaining({
|
||||
originalEntry: [String(primitive.id), 'value'],
|
||||
reason: 'primitiveBypassFailed'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps the target default when the primitive source widget has no value', () => {
|
||||
const host = buildHost()
|
||||
const { primitive } = addPrimitiveWithTargets(host, {
|
||||
targetCount: 1
|
||||
})
|
||||
primitive.widgets = []
|
||||
|
||||
host.properties.proxyWidgets = [[String(primitive.id), 'value']]
|
||||
flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
expect(getPromotedInputValue(host, 'value')).toBe(0)
|
||||
})
|
||||
|
||||
it('quarantines all cohort entries when a target slot type is incompatible', () => {
|
||||
const host = buildHost()
|
||||
const { primitive, targets } = addPrimitiveWithTargets(host, {
|
||||
@@ -570,73 +366,6 @@ describe('flushProxyWidgetMigration', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('quarantines primitive repair when the target slot disappeared', () => {
|
||||
const host = buildHost()
|
||||
const { primitive, targets } = addPrimitiveWithTargets(host, {
|
||||
targetCount: 1
|
||||
})
|
||||
targets[0].inputs = []
|
||||
|
||||
const inputCountBefore = host.subgraph.inputs.length
|
||||
host.properties.proxyWidgets = [[String(primitive.id), 'value']]
|
||||
flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
expect(host.subgraph.inputs).toHaveLength(inputCountBefore)
|
||||
expect(readHostQuarantine(host)).toEqual([
|
||||
expect.objectContaining({
|
||||
originalEntry: [String(primitive.id), 'value'],
|
||||
reason: 'primitiveBypassFailed'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('quarantines primitive repair when the target node id is stale', () => {
|
||||
const host = buildHost()
|
||||
const { primitive } = addPrimitiveWithTargets(host, {
|
||||
targetCount: 1
|
||||
})
|
||||
const linkId = primitive.outputs[0].links?.[0]
|
||||
if (!linkId) throw new Error('Missing primitive link')
|
||||
const link = host.subgraph.links.get(linkId)
|
||||
if (!link) throw new Error('Missing primitive link record')
|
||||
link.target_id = toNodeId(999_999)
|
||||
|
||||
host.properties.proxyWidgets = [[String(primitive.id), 'value']]
|
||||
flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
expect(readHostQuarantine(host)).toEqual([
|
||||
expect.objectContaining({
|
||||
originalEntry: [String(primitive.id), 'value'],
|
||||
reason: 'primitiveBypassFailed'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('quarantines duplicate primitive entries when the fan-out target is unassigned', () => {
|
||||
const host = buildHost()
|
||||
const { primitive } = addPrimitiveWithTargets(host, {
|
||||
targetCount: 1
|
||||
})
|
||||
const linkId = primitive.outputs[0].links?.[0]
|
||||
if (!linkId) throw new Error('Missing primitive link')
|
||||
const link = host.subgraph.links.get(linkId)
|
||||
if (!link) throw new Error('Missing primitive link record')
|
||||
link.target_id = UNASSIGNED_NODE_ID
|
||||
|
||||
host.properties.proxyWidgets = [
|
||||
[String(primitive.id), 'value'],
|
||||
[String(primitive.id), 'value']
|
||||
]
|
||||
flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
expect(readHostQuarantine(host)).toEqual([
|
||||
expect.objectContaining({
|
||||
originalEntry: [String(primitive.id), 'value'],
|
||||
reason: 'primitiveBypassFailed'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps surviving primitive targets when one fan-out link is dangling', () => {
|
||||
const host = buildHost()
|
||||
const { primitive } = addPrimitiveWithTargets(host, { targetCount: 1 })
|
||||
@@ -843,22 +572,6 @@ describe('flushProxyWidgetMigration', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('does not preserve non-widget host values on quarantine rows', () => {
|
||||
const host = buildHost()
|
||||
host.properties.proxyWidgets = [['9999', 'seed']]
|
||||
|
||||
flushProxyWidgetMigration({
|
||||
hostNode: host,
|
||||
hostWidgetValues: [null]
|
||||
})
|
||||
|
||||
expect(readHostQuarantine(host)).toEqual([
|
||||
expect.not.objectContaining({
|
||||
hostValue: expect.anything()
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('round-trips appended entries via the public read helper', () => {
|
||||
const host = buildHost()
|
||||
host.properties.proxyWidgets = [['9999', 'seed']]
|
||||
@@ -889,14 +602,6 @@ describe('flushProxyWidgetMigration', () => {
|
||||
|
||||
expect(readHostQuarantine(host)).toEqual(firstQuarantine)
|
||||
})
|
||||
|
||||
it('ignores empty quarantine append requests', () => {
|
||||
const host = buildHost()
|
||||
|
||||
appendQuarantine(host, [])
|
||||
|
||||
expect(host.properties.proxyWidgetErrorQuarantine).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('idempotency', () => {
|
||||
@@ -1119,22 +824,6 @@ describe('normalizeLegacyProxyWidgetEntry', () => {
|
||||
expect(result.disambiguatingSourceNodeId).toBe(String(samplerNode.id))
|
||||
})
|
||||
|
||||
it('strips nested legacy prefixes from widget name', () => {
|
||||
const { hostNode, innerNode } = createHostWithInnerWidget('seed')
|
||||
|
||||
const result = normalizeLegacyProxyWidgetEntry(
|
||||
hostNode,
|
||||
String(innerNode.id),
|
||||
'111: 222: seed'
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
sourceNodeId: String(innerNode.id),
|
||||
sourceWidgetName: 'seed',
|
||||
disambiguatingSourceNodeId: '222'
|
||||
})
|
||||
})
|
||||
|
||||
it('strips legacy prefix and surfaces it as disambiguator even when the bare name does not resolve', () => {
|
||||
const { hostNode, innerNode } = createHostWithInnerWidget('seed')
|
||||
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { WidgetId } from '@/types/widgetId'
|
||||
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
import {
|
||||
inputForWidget,
|
||||
promotedInputSource,
|
||||
promotedInputWidget,
|
||||
promotedInputWidgets,
|
||||
widgetPromotedSource
|
||||
} from './promotedInputWidget'
|
||||
import { resolveSubgraphInputTarget } from './resolveSubgraphInputTarget'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
widgets: new Map<string, Record<string, unknown>>(),
|
||||
setValue: vi.fn(),
|
||||
resolveSubgraphInputTarget: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/widgetValueStore', () => ({
|
||||
useWidgetValueStore: () => ({
|
||||
getWidget: (id: string) => mocks.widgets.get(id),
|
||||
setValue: mocks.setValue
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('./resolveSubgraphInputTarget', () => ({
|
||||
resolveSubgraphInputTarget: mocks.resolveSubgraphInputTarget
|
||||
}))
|
||||
|
||||
function input(overrides: Partial<INodeInputSlot> = {}): INodeInputSlot {
|
||||
return {
|
||||
name: 'prompt',
|
||||
type: 'STRING',
|
||||
label: 'Prompt',
|
||||
...overrides
|
||||
} as INodeInputSlot
|
||||
}
|
||||
|
||||
function node(overrides: Record<string, unknown> = {}): LGraphNode {
|
||||
return createMockLGraphNode({
|
||||
inputs: [],
|
||||
isSubgraphNode: () => true,
|
||||
getSlotFromWidget: vi.fn(),
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
|
||||
describe('promotedInputWidget helpers', () => {
|
||||
beforeEach(() => {
|
||||
mocks.widgets.clear()
|
||||
mocks.setValue.mockClear()
|
||||
mocks.resolveSubgraphInputTarget.mockReset()
|
||||
})
|
||||
|
||||
it('resolves promoted input sources only for widget-backed inputs', () => {
|
||||
const graphNode = node()
|
||||
mocks.resolveSubgraphInputTarget.mockReturnValue({
|
||||
nodeId: '12',
|
||||
widgetName: 'prompt'
|
||||
})
|
||||
|
||||
expect(promotedInputSource(graphNode, input())).toBeUndefined()
|
||||
expect(
|
||||
promotedInputSource(
|
||||
graphNode,
|
||||
input({ widgetId: 'graph:12:prompt' as WidgetId })
|
||||
)
|
||||
).toEqual({
|
||||
nodeId: '12',
|
||||
widgetName: 'prompt'
|
||||
})
|
||||
expect(resolveSubgraphInputTarget).toHaveBeenCalledWith(graphNode, 'prompt')
|
||||
})
|
||||
|
||||
it('resolves promoted widget sources only on subgraph nodes with matching inputs', () => {
|
||||
const widget = { name: 'prompt' } as IBaseWidget
|
||||
const backingInput = input({ widgetId: 'graph:12:prompt' as WidgetId })
|
||||
mocks.resolveSubgraphInputTarget.mockReturnValue({
|
||||
nodeId: '12',
|
||||
widgetName: 'prompt'
|
||||
})
|
||||
|
||||
expect(
|
||||
widgetPromotedSource(node({ isSubgraphNode: () => false }), widget)
|
||||
).toBeUndefined()
|
||||
expect(
|
||||
widgetPromotedSource(node({ getSlotFromWidget: () => undefined }), widget)
|
||||
).toBeUndefined()
|
||||
expect(
|
||||
widgetPromotedSource(
|
||||
node({ getSlotFromWidget: () => backingInput }),
|
||||
widget
|
||||
)
|
||||
).toEqual({
|
||||
nodeId: '12',
|
||||
widgetName: 'prompt'
|
||||
})
|
||||
})
|
||||
|
||||
it('projects store-backed widget fields with input fallbacks', () => {
|
||||
const widgetId = 'graph:12:prompt' as WidgetId
|
||||
const widget = promotedInputWidget(input({ widgetId }))
|
||||
|
||||
expect(widget?.name).toBe('prompt')
|
||||
expect(widget?.label).toBe('Prompt')
|
||||
expect(widget?.y).toBe(0)
|
||||
expect(widget?.type).toBe('text')
|
||||
expect(widget?.options).toEqual({})
|
||||
expect(widget?.value).toBeUndefined()
|
||||
|
||||
widget!.label = 'Ignored'
|
||||
widget!.y = 12
|
||||
widget!.value = 'next'
|
||||
widget!.callback?.('callback')
|
||||
|
||||
expect(mocks.setValue).toHaveBeenCalledWith(widgetId, 'next')
|
||||
expect(mocks.setValue).toHaveBeenCalledWith(widgetId, 'callback')
|
||||
})
|
||||
|
||||
it('projects live widget store fields and mutates store state', () => {
|
||||
const widgetId = 'graph:12:prompt' as WidgetId
|
||||
const state = {
|
||||
name: 'store-name',
|
||||
label: 'Store Label',
|
||||
y: 42,
|
||||
type: 'combo',
|
||||
options: { values: ['a'] },
|
||||
value: 'a'
|
||||
}
|
||||
mocks.widgets.set(widgetId, state)
|
||||
|
||||
const widget = promotedInputWidget(input({ widgetId, label: undefined }))
|
||||
|
||||
expect(widget?.name).toBe('store-name')
|
||||
expect(widget?.label).toBe('Store Label')
|
||||
expect(widget?.y).toBe(42)
|
||||
expect(widget?.type).toBe('combo')
|
||||
expect(widget?.options).toEqual({ values: ['a'] })
|
||||
expect(widget?.value).toBe('a')
|
||||
|
||||
widget!.label = 'New Label'
|
||||
widget!.y = 52
|
||||
|
||||
expect(state.label).toBe('New Label')
|
||||
expect(state.y).toBe(52)
|
||||
})
|
||||
|
||||
it('returns null for non-promoted inputs and filters projected widget lists', () => {
|
||||
const widgetId = 'graph:12:prompt' as WidgetId
|
||||
const graphNode = node({
|
||||
inputs: [input(), input({ widgetId })]
|
||||
})
|
||||
|
||||
expect(promotedInputWidget(input())).toBeNull()
|
||||
expect(promotedInputWidgets(graphNode)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('returns undefined for null stored values', () => {
|
||||
const widgetId = 'graph:12:prompt' as WidgetId
|
||||
mocks.widgets.set(widgetId, { value: null })
|
||||
|
||||
expect(promotedInputWidget(input({ widgetId }))?.value).toBeUndefined()
|
||||
})
|
||||
|
||||
it('delegates input lookup to the graph node', () => {
|
||||
const widget = { name: 'prompt' } as IBaseWidget
|
||||
const backingInput = input({ widgetId: 'graph:12:prompt' as WidgetId })
|
||||
const graphNode = node({
|
||||
getSlotFromWidget: vi.fn(() => backingInput)
|
||||
})
|
||||
|
||||
expect(inputForWidget(graphNode, widget)).toBe(backingInput)
|
||||
expect(graphNode.getSlotFromWidget).toHaveBeenCalledWith(widget)
|
||||
})
|
||||
})
|
||||
@@ -15,10 +15,6 @@ import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { toLinkId } from '@/types/linkId'
|
||||
import type { WidgetId } from '@/types/widgetId'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import type { Subgraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
function promotedInputNames(host: {
|
||||
inputs: Array<{ widgetId?: unknown; name: string }>
|
||||
@@ -55,37 +51,19 @@ vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => ({ updatePreviews: updatePreviewsMock })
|
||||
}))
|
||||
|
||||
const addBreadcrumbMock = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@sentry/vue', () => ({
|
||||
addBreadcrumb: addBreadcrumbMock
|
||||
}))
|
||||
|
||||
const mockNavigation = vi.hoisted(() => ({
|
||||
stack: [] as Subgraph[]
|
||||
}))
|
||||
vi.mock('@/stores/subgraphNavigationStore', () => ({
|
||||
useSubgraphNavigationStore: () => ({
|
||||
navigationStack: mockNavigation.stack
|
||||
})
|
||||
}))
|
||||
|
||||
import {
|
||||
CANVAS_IMAGE_PREVIEW_WIDGET,
|
||||
addWidgetPromotionOptions,
|
||||
autoExposeKnownPreviewNodes,
|
||||
demoteWidget,
|
||||
getPromotableWidgets,
|
||||
hasUnpromotedWidgets,
|
||||
isLinkedPromotion,
|
||||
isPreviewPseudoWidget,
|
||||
isWidgetPromotedOnSubgraphNode,
|
||||
promoteWidget,
|
||||
promoteValueWidgetViaSubgraphInput,
|
||||
promoteRecommendedWidgets,
|
||||
pruneDisconnected,
|
||||
reorderSubgraphInputsByName,
|
||||
reorderSubgraphInputsByWidgetOrder,
|
||||
tryToggleWidgetPromotion
|
||||
reorderSubgraphInputsByWidgetOrder
|
||||
} from './promotionUtils'
|
||||
|
||||
function widget(
|
||||
@@ -124,11 +102,6 @@ function buildDuplicateNamePromotion() {
|
||||
return { subgraph, host, nodeA, widgetA, nodeB, widgetB }
|
||||
}
|
||||
|
||||
function setupNavigation(host: SubgraphNode) {
|
||||
host.subgraph.rootGraph.add(host)
|
||||
mockNavigation.stack = [host.subgraph]
|
||||
}
|
||||
|
||||
describe('isPreviewPseudoWidget', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
@@ -330,284 +303,6 @@ describe('getPromotableWidgets', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('widget promotion actions', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
addBreadcrumbMock.mockReset()
|
||||
mockNavigation.stack = []
|
||||
})
|
||||
|
||||
function setupPromotableWidget() {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
setupNavigation(host)
|
||||
const node = new LGraphNode('Prompt')
|
||||
subgraph.add(node)
|
||||
const input = node.addInput('text', 'STRING')
|
||||
input.label = 'Prompt text'
|
||||
const callback = vi.fn()
|
||||
const textWidget = node.addWidget('text', 'text', 'value', callback)
|
||||
textWidget.label = 'Prompt'
|
||||
input.widget = { name: textWidget.name }
|
||||
return { host, node, textWidget, callback }
|
||||
}
|
||||
|
||||
it('adds a promote menu option and runs the widget callback after promotion', () => {
|
||||
const { host, node, textWidget, callback } = setupPromotableWidget()
|
||||
const options: Parameters<typeof addWidgetPromotionOptions>[0] = []
|
||||
|
||||
addWidgetPromotionOptions(options, textWidget, node)
|
||||
const menuCallback = options[0]?.callback as
|
||||
| ((...args: unknown[]) => unknown)
|
||||
| undefined
|
||||
void menuCallback?.(null, undefined, undefined)
|
||||
|
||||
expect(options[0]?.content).toContain('Prompt')
|
||||
expect(isLinkedPromotion(host, String(node.id), textWidget.name)).toBe(true)
|
||||
expect(callback).toHaveBeenCalledWith('value')
|
||||
})
|
||||
|
||||
it('adds an unpromote menu option when the widget is already promoted', () => {
|
||||
const { host, node, textWidget, callback } = setupPromotableWidget()
|
||||
expect(promoteValueWidgetViaSubgraphInput(host, node, textWidget).ok).toBe(
|
||||
true
|
||||
)
|
||||
const options: Parameters<typeof addWidgetPromotionOptions>[0] = []
|
||||
|
||||
addWidgetPromotionOptions(options, textWidget, node)
|
||||
const menuCallback = options[0]?.callback as
|
||||
| ((...args: unknown[]) => unknown)
|
||||
| undefined
|
||||
void menuCallback?.(null, undefined, undefined)
|
||||
|
||||
expect(isLinkedPromotion(host, String(node.id), textWidget.name)).toBe(
|
||||
false
|
||||
)
|
||||
expect(callback).toHaveBeenCalledWith('value')
|
||||
})
|
||||
|
||||
it('reports outside-subgraph promotion attempts through the toast store', () => {
|
||||
const node = new LGraphNode('Prompt')
|
||||
const textWidget = node.addWidget('text', 'text', 'value', () => {})
|
||||
const options: Parameters<typeof addWidgetPromotionOptions>[0] = []
|
||||
|
||||
addWidgetPromotionOptions(options, textWidget, node)
|
||||
|
||||
expect(useToastStore().messagesToAdd).toHaveLength(1)
|
||||
expect(options).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('toggles promotion for the widget under the canvas pointer', () => {
|
||||
const { host, node, textWidget } = setupPromotableWidget()
|
||||
const canvas = fromPartial<ReturnType<typeof useCanvasStore>['canvas']>({
|
||||
graph_mouse: [10, 20],
|
||||
visible_nodes: [node],
|
||||
setDirty: vi.fn(),
|
||||
graph: {
|
||||
getNodeOnPos: vi.fn(() => node)
|
||||
}
|
||||
})
|
||||
vi.spyOn(node, 'getWidgetOnPos').mockReturnValue(textWidget)
|
||||
useCanvasStore().canvas = canvas
|
||||
|
||||
tryToggleWidgetPromotion()
|
||||
expect(isLinkedPromotion(host, String(node.id), textWidget.name)).toBe(true)
|
||||
|
||||
tryToggleWidgetPromotion()
|
||||
expect(isLinkedPromotion(host, String(node.id), textWidget.name)).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves state unchanged when toggle has no node or widget target', () => {
|
||||
const { host, node, textWidget } = setupPromotableWidget()
|
||||
useCanvasStore().canvas = fromPartial<
|
||||
ReturnType<typeof useCanvasStore>['canvas']
|
||||
>({
|
||||
graph_mouse: [0, 0],
|
||||
visible_nodes: [],
|
||||
setDirty: vi.fn(),
|
||||
graph: {
|
||||
getNodeOnPos: vi.fn(() => null)
|
||||
}
|
||||
})
|
||||
|
||||
tryToggleWidgetPromotion()
|
||||
expect(isLinkedPromotion(host, String(node.id), textWidget.name)).toBe(
|
||||
false
|
||||
)
|
||||
|
||||
useCanvasStore().canvas = fromPartial<
|
||||
ReturnType<typeof useCanvasStore>['canvas']
|
||||
>({
|
||||
graph_mouse: [0, 0],
|
||||
visible_nodes: [node],
|
||||
setDirty: vi.fn(),
|
||||
graph: {
|
||||
getNodeOnPos: vi.fn(() => node)
|
||||
}
|
||||
})
|
||||
vi.spyOn(node, 'getWidgetOnPos').mockReturnValue(undefined)
|
||||
|
||||
tryToggleWidgetPromotion()
|
||||
expect(isLinkedPromotion(host, String(node.id), textWidget.name)).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('records a breadcrumb when value promotion has no source slot', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const node = new LGraphNode('LooseWidgetNode')
|
||||
subgraph.add(node)
|
||||
const looseWidget = node.addWidget('text', 'loose', 'value', () => {})
|
||||
|
||||
promoteWidget(node, looseWidget, [host])
|
||||
|
||||
expect(addBreadcrumbMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
level: 'warning',
|
||||
message: expect.stringContaining('missingSourceSlot')
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('ignores promotion calls for node-shaped values that are not graph nodes', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const partialNode = {
|
||||
id: toNodeId(123),
|
||||
title: 'Partial',
|
||||
type: 'Partial'
|
||||
}
|
||||
|
||||
promoteWidget(partialNode, widget({ name: 'seed', type: 'number' }), [host])
|
||||
|
||||
expect(host.subgraph.inputs).toEqual([])
|
||||
expect(addBreadcrumbMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses the widget name in menu text when label is absent', () => {
|
||||
const { node, textWidget } = setupPromotableWidget()
|
||||
textWidget.label = undefined
|
||||
const options: Parameters<typeof addWidgetPromotionOptions>[0] = []
|
||||
|
||||
addWidgetPromotionOptions(options, textWidget, node)
|
||||
|
||||
expect(options[0]?.content).toContain('text')
|
||||
})
|
||||
})
|
||||
|
||||
describe('preview promotion actions', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
addBreadcrumbMock.mockReset()
|
||||
mockNavigation.stack = []
|
||||
})
|
||||
|
||||
it('identifies preview exposure as promotion only for preview pseudo widgets', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const previewNode = new LGraphNode('PreviewImage')
|
||||
previewNode.type = 'PreviewImage'
|
||||
subgraph.add(previewNode)
|
||||
const previewWidget = widget({
|
||||
name: CANVAS_IMAGE_PREVIEW_WIDGET,
|
||||
serialize: false,
|
||||
type: 'preview'
|
||||
})
|
||||
usePreviewExposureStore().addExposure(host.rootGraph.id, String(host.id), {
|
||||
sourceNodeId: previewNode.id,
|
||||
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
|
||||
})
|
||||
|
||||
expect(
|
||||
isWidgetPromotedOnSubgraphNode(
|
||||
host,
|
||||
{
|
||||
sourceNodeId: previewNode.id,
|
||||
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET
|
||||
},
|
||||
previewWidget
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
isWidgetPromotedOnSubgraphNode(
|
||||
host,
|
||||
{
|
||||
sourceNodeId: previewNode.id,
|
||||
sourceWidgetName: 'other'
|
||||
},
|
||||
previewWidget
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('deduplicates preview exposures when the same preview is promoted twice', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const previewNode = new LGraphNode('PreviewImage')
|
||||
previewNode.type = 'PreviewImage'
|
||||
subgraph.add(previewNode)
|
||||
const previewWidget = widget({
|
||||
name: CANVAS_IMAGE_PREVIEW_WIDGET,
|
||||
serialize: false,
|
||||
type: 'preview'
|
||||
})
|
||||
|
||||
promoteWidget(previewNode, previewWidget, [host])
|
||||
promoteWidget(previewNode, previewWidget, [host])
|
||||
|
||||
expect(
|
||||
usePreviewExposureStore().getExposures(host.rootGraph.id, String(host.id))
|
||||
).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('demotes preview exposures when no linked value promotion exists', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const previewNode = new LGraphNode('PreviewImage')
|
||||
previewNode.type = 'PreviewImage'
|
||||
subgraph.add(previewNode)
|
||||
const previewWidget = widget({
|
||||
name: CANVAS_IMAGE_PREVIEW_WIDGET,
|
||||
serialize: false,
|
||||
type: 'preview'
|
||||
})
|
||||
promoteWidget(previewNode, previewWidget, [host])
|
||||
|
||||
demoteWidget(previewNode, previewWidget, [host])
|
||||
|
||||
expect(
|
||||
usePreviewExposureStore().getExposures(host.rootGraph.id, String(host.id))
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('leaves unexposed preview widgets unchanged when demoted', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const previewNode = new LGraphNode('PreviewImage')
|
||||
previewNode.type = 'PreviewImage'
|
||||
subgraph.add(previewNode)
|
||||
const previewWidget = widget({
|
||||
name: CANVAS_IMAGE_PREVIEW_WIDGET,
|
||||
serialize: false,
|
||||
type: 'preview'
|
||||
})
|
||||
|
||||
demoteWidget(previewNode, previewWidget, [host])
|
||||
|
||||
expect(
|
||||
usePreviewExposureStore().getExposures(host.rootGraph.id, String(host.id))
|
||||
).toEqual([])
|
||||
expect(addBreadcrumbMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining(CANVAS_IMAGE_PREVIEW_WIDGET)
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('promoteRecommendedWidgets', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
@@ -651,49 +346,6 @@ describe('promoteRecommendedWidgets', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps value promotion idempotent when the widget is already linked', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const interiorNode = new LGraphNode('Prompt')
|
||||
const input = interiorNode.addInput('text', 'STRING')
|
||||
const textWidget = interiorNode.addWidget('text', 'text', '', () => {})
|
||||
input.widget = { name: textWidget.name }
|
||||
subgraph.add(interiorNode)
|
||||
|
||||
expect(
|
||||
promoteValueWidgetViaSubgraphInput(subgraphNode, interiorNode, textWidget)
|
||||
.ok
|
||||
).toBe(true)
|
||||
expect(
|
||||
promoteValueWidgetViaSubgraphInput(subgraphNode, interiorNode, textWidget)
|
||||
.ok
|
||||
).toBe(true)
|
||||
|
||||
expect(subgraph.inputs.map((slot) => slot.name)).toEqual(['text'])
|
||||
})
|
||||
|
||||
it('seeds outer promoted widget state from a nested promoted input', () => {
|
||||
const { host: innerHost } = buildDuplicateNamePromotion()
|
||||
writePromotedInputValue(innerHost, 'text', 'inner value')
|
||||
const outerSubgraph = createTestSubgraph()
|
||||
const outerHost = createTestSubgraphNode(outerSubgraph)
|
||||
outerSubgraph.add(innerHost)
|
||||
|
||||
expect(
|
||||
promoteValueWidgetViaSubgraphInput(
|
||||
outerHost,
|
||||
innerHost,
|
||||
promotedWidgetRef(innerHost, 'text')
|
||||
).ok
|
||||
).toBe(true)
|
||||
|
||||
const hostInput = outerHost.inputs.find((input) => input.name === 'text')
|
||||
if (!hostInput?.widgetId) throw new Error('Missing promoted host widget id')
|
||||
expect(useWidgetValueStore().getWidget(hostInput.widgetId)?.value).toBe(
|
||||
'inner value'
|
||||
)
|
||||
})
|
||||
|
||||
it('promotes virtual previews through preview exposures', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
@@ -762,24 +414,6 @@ describe('promoteRecommendedWidgets', () => {
|
||||
})
|
||||
expect(updatePreviewsMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('records a breadcrumb when a recommended value widget has no source slot', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const interiorNode = new LGraphNode('CLIPTextEncode')
|
||||
interiorNode.type = 'CLIPTextEncode'
|
||||
interiorNode.addWidget('text', 'text', '', () => {})
|
||||
subgraph.add(interiorNode)
|
||||
|
||||
promoteRecommendedWidgets(subgraphNode)
|
||||
|
||||
expect(addBreadcrumbMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
level: 'warning',
|
||||
message: expect.stringContaining('missingSourceSlot')
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('autoExposeKnownPreviewNodes', () => {
|
||||
@@ -848,52 +482,6 @@ describe('autoExposeKnownPreviewNodes', () => {
|
||||
.map((e) => e.sourceNodeId)
|
||||
).not.toContain(String(glslNode.id))
|
||||
})
|
||||
|
||||
it('defers preview discovery for nodes without eager preview widgets', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const interiorNode = new LGraphNode('DeferredPreview')
|
||||
const rafCallbacks: FrameRequestCallback[] = []
|
||||
const requestAnimationFrameSpy = vi
|
||||
.spyOn(window, 'requestAnimationFrame')
|
||||
.mockImplementation((callback) => {
|
||||
rafCallbacks.push(callback)
|
||||
return rafCallbacks.length
|
||||
})
|
||||
subgraph.add(interiorNode)
|
||||
|
||||
try {
|
||||
autoExposeKnownPreviewNodes(subgraphNode)
|
||||
rafCallbacks[0]?.(0)
|
||||
const updateCallback = updatePreviewsMock.mock.calls[0]?.[1]
|
||||
const previewWidget = interiorNode.addWidget(
|
||||
'preview' as Parameters<typeof interiorNode.addWidget>[0],
|
||||
'preview',
|
||||
'',
|
||||
() => {}
|
||||
)
|
||||
previewWidget.serialize = false
|
||||
previewWidget.type = 'preview'
|
||||
updateCallback?.()
|
||||
|
||||
expect(updatePreviewsMock).toHaveBeenCalledWith(
|
||||
interiorNode,
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(
|
||||
usePreviewExposureStore().getExposures(
|
||||
subgraphNode.rootGraph.id,
|
||||
String(subgraphNode.id)
|
||||
)
|
||||
).toContainEqual({
|
||||
name: 'preview',
|
||||
sourceNodeId: String(interiorNode.id),
|
||||
sourcePreviewName: 'preview'
|
||||
})
|
||||
} finally {
|
||||
requestAnimationFrameSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasUnpromotedWidgets', () => {
|
||||
@@ -1085,25 +673,6 @@ describe('reorderSubgraphInputsByName', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('leaves unordered names after explicitly ordered inputs', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [
|
||||
{ name: 'first', type: 'number' },
|
||||
{ name: 'second', type: 'number' },
|
||||
{ name: 'third', type: 'number' }
|
||||
]
|
||||
})
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
|
||||
reorderSubgraphInputsByName(host, ['second'])
|
||||
|
||||
expect(host.subgraph.inputs.map((input) => input.name)).toEqual([
|
||||
'second',
|
||||
'first',
|
||||
'third'
|
||||
])
|
||||
})
|
||||
|
||||
it('updates subgraph input link slot indices after reordering', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
@@ -1199,33 +768,6 @@ describe('reorderSubgraphInputsByWidgetOrder', () => {
|
||||
'first value'
|
||||
])
|
||||
})
|
||||
|
||||
it('appends promoted inputs that are absent from the widget order', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const firstNode = new LGraphNode('First')
|
||||
const secondNode = new LGraphNode('Second')
|
||||
subgraph.add(firstNode)
|
||||
subgraph.add(secondNode)
|
||||
|
||||
const firstInput = firstNode.addInput('first', 'STRING')
|
||||
const firstWidget = firstNode.addWidget('text', 'first', '', () => {})
|
||||
firstInput.widget = { name: firstWidget.name }
|
||||
const secondInput = secondNode.addInput('second', 'STRING')
|
||||
const secondWidget = secondNode.addWidget('text', 'second', '', () => {})
|
||||
secondInput.widget = { name: secondWidget.name }
|
||||
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
|
||||
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
|
||||
|
||||
reorderSubgraphInputsByWidgetOrder(host, [
|
||||
promotedWidgetRef(host, 'second')
|
||||
])
|
||||
|
||||
expect(host.subgraph.inputs.map((input) => input.name)).toEqual([
|
||||
'second',
|
||||
'first'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('demoteWidget — axiomatic projection retraction', () => {
|
||||
@@ -1256,23 +798,6 @@ describe('demoteWidget — axiomatic projection retraction', () => {
|
||||
return { host, interiorNode, interiorWidget }
|
||||
}
|
||||
|
||||
it('runs as a no-op for an unpromoted non-preview widget', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const interiorNode = new LGraphNode('TestNode')
|
||||
host.subgraph.add(interiorNode)
|
||||
const widget = interiorNode.addWidget('text', 'value', 'initial', () => {})
|
||||
|
||||
demoteWidget(interiorNode, widget, [host])
|
||||
|
||||
expect(host.subgraph.inputs).toEqual([])
|
||||
expect(addBreadcrumbMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining('Demoted widget "value"')
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('drops projection but keeps slot and external link when host slot is externally connected', () => {
|
||||
const { host, interiorNode, interiorWidget } = setupPromotedWidget()
|
||||
const hostInput = host.inputs[0]
|
||||
@@ -1418,54 +943,4 @@ describe('disambiguated nested promotion identity', () => {
|
||||
|
||||
expect(outerHost.subgraph.inputs).toHaveLength(beforeCount)
|
||||
})
|
||||
|
||||
it('promotes a widget whose source widget state is missing', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const interiorNode = new LGraphNode('Source')
|
||||
subgraph.add(interiorNode)
|
||||
const interiorInput = interiorNode.addInput('text', 'STRING')
|
||||
const interiorWidget = interiorNode.addWidget('text', 'text', '', () => {})
|
||||
interiorInput.widget = { name: interiorWidget.name }
|
||||
interiorInput.widgetId = 'missing-widget-state' as WidgetId
|
||||
|
||||
expect(
|
||||
promoteValueWidgetViaSubgraphInput(host, interiorNode, interiorWidget).ok
|
||||
).toBe(true)
|
||||
expect(host.subgraph.inputs.map((input) => input.name)).toEqual(['text'])
|
||||
})
|
||||
|
||||
it('keeps plain inputs after ordered promoted widgets', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'plain', type: 'STRING' }]
|
||||
})
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
|
||||
reorderSubgraphInputsByWidgetOrder(host, [
|
||||
{ widgetId: 'missing-widget-state' as WidgetId }
|
||||
])
|
||||
|
||||
expect(host.inputs.map((input) => input.name)).toEqual(['plain'])
|
||||
})
|
||||
|
||||
it('falls back to append order when promoted input links are stale', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const interiorNode = new LGraphNode('Source')
|
||||
subgraph.add(interiorNode)
|
||||
const interiorInput = interiorNode.addInput('text', 'STRING')
|
||||
const interiorWidget = interiorNode.addWidget('text', 'text', '', () => {})
|
||||
interiorInput.widget = { name: interiorWidget.name }
|
||||
|
||||
expect(
|
||||
promoteValueWidgetViaSubgraphInput(host, interiorNode, interiorWidget).ok
|
||||
).toBe(true)
|
||||
const promotedInput = host.subgraph.inputs[0]
|
||||
const linkId = promotedInput.linkIds[0]
|
||||
host.subgraph.links.delete(linkId)
|
||||
|
||||
reorderSubgraphInputsByWidgetOrder(host, [promotedWidgetRef(host, 'text')])
|
||||
|
||||
expect(host.inputs.map((input) => input.name)).toEqual(['text'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { resolveInputType } from './dynamicTypes'
|
||||
|
||||
describe('resolveInputType', () => {
|
||||
it('splits concrete comma-delimited input types', () => {
|
||||
expect(resolveInputType({ type: 'MODEL,CLIP' } as never)).toEqual([
|
||||
'MODEL',
|
||||
'CLIP'
|
||||
])
|
||||
})
|
||||
|
||||
it('resolves match-type templates from allowed types', () => {
|
||||
expect(
|
||||
resolveInputType({
|
||||
type: 'COMFY_MATCHTYPE_V3',
|
||||
template: {
|
||||
allowed_types: 'IMAGE,MASK',
|
||||
template_id: 'image'
|
||||
}
|
||||
} as never)
|
||||
).toEqual(['IMAGE', 'MASK'])
|
||||
})
|
||||
|
||||
it('returns an empty type list for invalid match-type templates', () => {
|
||||
expect(resolveInputType({ type: 'COMFY_MATCHTYPE_V3' } as never)).toEqual(
|
||||
[]
|
||||
)
|
||||
})
|
||||
|
||||
it('resolves autogrow templates from required and optional inputs', () => {
|
||||
expect(
|
||||
resolveInputType({
|
||||
type: 'COMFY_AUTOGROW_V3',
|
||||
template: {
|
||||
input: {
|
||||
required: {
|
||||
image: ['IMAGE', {}]
|
||||
},
|
||||
optional: {
|
||||
mask: ['MASK,IMAGE', {}]
|
||||
}
|
||||
}
|
||||
}
|
||||
} as never)
|
||||
).toEqual(['IMAGE', 'MASK', 'IMAGE'])
|
||||
})
|
||||
|
||||
it('returns an empty type list for invalid autogrow templates', () => {
|
||||
expect(resolveInputType({ type: 'COMFY_AUTOGROW_V3' } as never)).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,19 +1,13 @@
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
import { LGraph, LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { describe, expect, test, vi } from 'vitest'
|
||||
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
|
||||
import { app } from '@/scripts/app'
|
||||
import type { InputSpec } from '@/schemas/nodeDefSchema'
|
||||
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import type { HasInitialMinSize } from '@/services/litegraphService'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { toLinkId } from '@/types/linkId'
|
||||
import { applyDynamicInputs, dynamicWidgets } from './dynamicWidgets'
|
||||
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
setActivePinia(createTestingPinia())
|
||||
type DynamicInputs = ('INT' | 'STRING' | 'IMAGE' | DynamicInputs)[][]
|
||||
type TestAutogrowNode = LGraphNode & {
|
||||
comfyDynamic: { autogrow: Record<string, unknown> }
|
||||
@@ -21,15 +15,6 @@ type TestAutogrowNode = LGraphNode & {
|
||||
|
||||
const { addNodeInput } = useLitegraphService()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
function mockConfiguringGraph() {
|
||||
vi.spyOn(app, 'configuringGraph', 'get').mockReturnValue(true)
|
||||
}
|
||||
|
||||
function nextTick() {
|
||||
return new Promise<void>((r) => requestAnimationFrame(() => r()))
|
||||
}
|
||||
@@ -71,23 +56,6 @@ function addAutogrow(node: LGraphNode, template: unknown) {
|
||||
})
|
||||
)
|
||||
}
|
||||
function addMatchType(
|
||||
node: LGraphNode,
|
||||
name: string,
|
||||
allowedTypes = '*',
|
||||
templateId = 'a'
|
||||
) {
|
||||
addNodeInput(
|
||||
node,
|
||||
transformInputSpecV1ToV2(
|
||||
[
|
||||
'COMFY_MATCHTYPE_V3',
|
||||
{ template: { allowed_types: allowedTypes, template_id: templateId } }
|
||||
],
|
||||
{ name, isOptional: false }
|
||||
)
|
||||
)
|
||||
}
|
||||
function connectInput(node: LGraphNode, inputIndex: number, graph: LGraph) {
|
||||
const node2 = testNode()
|
||||
node2.addOutput('out', '*')
|
||||
@@ -148,312 +116,7 @@ describe('Dynamic Combos', () => {
|
||||
node.widgets[0].value = '1'
|
||||
expect.soft(node.widgets[1].tooltip).toBe('1')
|
||||
})
|
||||
|
||||
test('throws for malformed dynamic combo specs before creating a widget', () => {
|
||||
const node = testNode()
|
||||
const comboApp = fromPartial<
|
||||
Parameters<typeof dynamicWidgets.COMFY_DYNAMICCOMBO_V3>[3]
|
||||
>({ widgets: { COMBO: vi.fn() } })
|
||||
|
||||
expect(() =>
|
||||
dynamicWidgets.COMFY_DYNAMICCOMBO_V3(
|
||||
node,
|
||||
'bad',
|
||||
['COMFY_DYNAMICCOMBO_V3', {}] as InputSpec,
|
||||
comboApp
|
||||
)
|
||||
).toThrow('invalid DynamicCombo spec')
|
||||
expect(comboApp.widgets.COMBO).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('clears grouped widgets when selection becomes empty', () => {
|
||||
const node = testNode()
|
||||
addDynamicCombo(node, [['INT'], ['INT', 'STRING']])
|
||||
node.widgets[0].value = '1'
|
||||
const onRemove = vi.fn()
|
||||
node.widgets[1].onRemove = onRemove
|
||||
|
||||
node.widgets[0].value = undefined
|
||||
|
||||
expect(onRemove).toHaveBeenCalled()
|
||||
expect(node.widgets).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('deletes widget state when removing grouped dynamic widgets', () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
graph.add(node)
|
||||
addDynamicCombo(node, [['INT'], ['STRING']])
|
||||
const childWidget = node.widgets[1]
|
||||
const childWidgetId = childWidget.widgetId
|
||||
if (!childWidgetId) throw new Error('Missing child widget id')
|
||||
const deleteWidget = vi.mocked(useWidgetValueStore().deleteWidget)
|
||||
|
||||
node.widgets[0].value = undefined
|
||||
|
||||
expect(deleteWidget).toHaveBeenCalledWith(childWidgetId)
|
||||
})
|
||||
|
||||
test('preserves an existing dynamic input link when refreshing a selection', () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
const onConnectionsChange = vi.fn()
|
||||
node.onConnectionsChange = onConnectionsChange
|
||||
graph.add(node)
|
||||
addDynamicCombo(node, [['IMAGE'], ['STRING']])
|
||||
node.widgets[0].value = '0'
|
||||
|
||||
connectInput(node, 1, graph)
|
||||
const linkId = node.inputs[1].link
|
||||
expect(linkId).not.toBeNull()
|
||||
onConnectionsChange.mockClear()
|
||||
|
||||
node.widgets[0].value = '0'
|
||||
|
||||
expect(node.inputs[1].link).toBe(linkId)
|
||||
expect(graph.links[linkId!].target_slot).toBe(1)
|
||||
expect(onConnectionsChange).toHaveBeenCalledWith(
|
||||
LiteGraph.INPUT,
|
||||
1,
|
||||
true,
|
||||
graph.links[linkId!],
|
||||
node.inputs[1]
|
||||
)
|
||||
})
|
||||
|
||||
test('throws if the backing widgets array disappears during update', () => {
|
||||
const node: LGraphNode = testNode()
|
||||
addDynamicCombo(node, [['INT'], ['STRING']])
|
||||
const controller = node.widgets![0]
|
||||
node.widgets = undefined
|
||||
|
||||
expect(() => {
|
||||
controller.value = '1'
|
||||
}).toThrow('Not Reachable')
|
||||
})
|
||||
|
||||
test('throws when the dynamic controller widget is missing during update', () => {
|
||||
const node = testNode()
|
||||
addDynamicCombo(node, [['INT'], ['STRING']])
|
||||
const controller = node.widgets[0]
|
||||
node.widgets = node.widgets.slice(1)
|
||||
|
||||
expect(() => {
|
||||
controller.value = '1'
|
||||
}).toThrow("Dynamic widget doesn't exist on node")
|
||||
})
|
||||
|
||||
test('throws when input-only dynamic sockets have no insertion point', () => {
|
||||
const node = testNode()
|
||||
addDynamicCombo(node, [['INT'], ['IMAGE']])
|
||||
const controller = node.widgets[0]
|
||||
node.inputs = []
|
||||
|
||||
expect(() => {
|
||||
controller.value = '1'
|
||||
}).toThrow('Failed to find input socket for 0')
|
||||
})
|
||||
|
||||
test('updates dynamic inputs without requiring a graph', () => {
|
||||
const node = testNode()
|
||||
addDynamicCombo(node, [['INT'], ['IMAGE']])
|
||||
|
||||
node.widgets[0].value = '1'
|
||||
|
||||
expect(node.inputs[1].type).toBe('IMAGE')
|
||||
})
|
||||
|
||||
test('reads dynamic combo values from widget state when available', () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
graph.add(node)
|
||||
addDynamicCombo(node, [['INT'], ['STRING']])
|
||||
const controller = node.widgets[0]
|
||||
const controllerId = controller.widgetId
|
||||
if (!controllerId) throw new Error('Missing controller widget id')
|
||||
|
||||
controller.value = '1'
|
||||
useWidgetValueStore().setValue(controllerId, '0')
|
||||
|
||||
expect(controller.value).toBe('0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Dynamic input dispatch', () => {
|
||||
test('returns false for unknown dynamic input types', () => {
|
||||
const node = testNode()
|
||||
|
||||
expect(
|
||||
applyDynamicInputs(node, {
|
||||
name: 'plain',
|
||||
type: 'STRING',
|
||||
isOptional: false
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
test('returns true after applying a known dynamic input type', () => {
|
||||
const node = testNode()
|
||||
|
||||
expect(
|
||||
applyDynamicInputs(
|
||||
node,
|
||||
transformInputSpecV1ToV2(
|
||||
[
|
||||
'COMFY_AUTOGROW_V3',
|
||||
{ template: { input: { required: { image: ['IMAGE', {}] } } } }
|
||||
],
|
||||
{ name: 'grow', isOptional: false }
|
||||
)
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('throws when an autogrow input spec is malformed', () => {
|
||||
const node = testNode()
|
||||
const inputSpec = {
|
||||
name: 'bad',
|
||||
type: 'COMFY_AUTOGROW_V3'
|
||||
} as InputSpecV2
|
||||
|
||||
expect(() => addNodeInput(node, inputSpec)).toThrow('invalid Autogrow spec')
|
||||
})
|
||||
|
||||
test('ignores malformed match type specs', () => {
|
||||
const node = testNode()
|
||||
|
||||
expect(
|
||||
applyDynamicInputs(node, {
|
||||
name: 'bad',
|
||||
type: 'COMFY_MATCHTYPE_V3',
|
||||
isOptional: false
|
||||
})
|
||||
).toBe(true)
|
||||
expect(node.inputs).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('MatchType inputs', () => {
|
||||
function createMatchTypeNode(graph: LGraph, outputMatchTypes = ['a']) {
|
||||
const node = testNode()
|
||||
node.constructor.nodeData = {
|
||||
name: 'testnode',
|
||||
output_matchtypes: outputMatchTypes
|
||||
} as typeof node.constructor.nodeData
|
||||
node.addOutput('out', '*')
|
||||
graph.add(node)
|
||||
addMatchType(node, 'on_true')
|
||||
addMatchType(node, 'on_false')
|
||||
return node
|
||||
}
|
||||
|
||||
function createSourceNode(graph: LGraph, type: string) {
|
||||
const node = testNode()
|
||||
node.addOutput('out', type)
|
||||
graph.add(node)
|
||||
return node
|
||||
}
|
||||
|
||||
test('ignores match type notifications outside registered inputs', () => {
|
||||
const graph = new LGraph()
|
||||
const node = createMatchTypeNode(graph)
|
||||
node.addInput('plain', 'STRING')
|
||||
|
||||
node.onConnectionsChange?.(LiteGraph.OUTPUT, 0, true, null, node.inputs[0])
|
||||
node.onConnectionsChange?.(LiteGraph.INPUT, 2, true, null, node.inputs[2])
|
||||
|
||||
expect(node.outputs[0].type).toBe('*')
|
||||
})
|
||||
|
||||
test('uses wildcard types for stale match type links', () => {
|
||||
const graph = new LGraph()
|
||||
const node = createMatchTypeNode(graph)
|
||||
node.inputs[0].link = toLinkId(999)
|
||||
|
||||
node.onConnectionsChange?.(LiteGraph.INPUT, 1, false, null, node.inputs[1])
|
||||
|
||||
expect(node.outputs[0].type).toBe('*')
|
||||
})
|
||||
|
||||
test('leaves unmatched output groups unchanged', () => {
|
||||
const graph = new LGraph()
|
||||
const node = createMatchTypeNode(graph, ['other'])
|
||||
const source = createSourceNode(graph, 'IMAGE')
|
||||
|
||||
source.connect(0, node, 0)
|
||||
|
||||
expect(node.outputs[0].type).toBe('*')
|
||||
})
|
||||
|
||||
test('throws when match group input constraints cannot overlap', () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
const requestAnimationFrameSpy = vi
|
||||
.spyOn(window, 'requestAnimationFrame')
|
||||
.mockImplementation(() => 1)
|
||||
node.constructor.nodeData = {
|
||||
name: 'testnode',
|
||||
output_matchtypes: ['a']
|
||||
} as typeof node.constructor.nodeData
|
||||
node.addOutput('out', '*')
|
||||
graph.add(node)
|
||||
addMatchType(node, 'image', 'IMAGE')
|
||||
addMatchType(node, 'latent', 'LATENT')
|
||||
const source = createSourceNode(graph, 'IMAGE')
|
||||
|
||||
try {
|
||||
expect(() => source.connect(0, node, 0)).toThrow('invalid connection')
|
||||
} finally {
|
||||
requestAnimationFrameSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test('disconnects downstream links when a match type output narrows', () => {
|
||||
const graph = new LGraph()
|
||||
const node = createMatchTypeNode(graph)
|
||||
const downstream = testNode()
|
||||
downstream.addInput('latent', 'LATENT')
|
||||
downstream.onConnectionsChange = vi.fn()
|
||||
graph.add(downstream)
|
||||
node.connect(0, downstream, 0)
|
||||
const source = createSourceNode(graph, 'IMAGE')
|
||||
|
||||
source.connect(0, node, 0)
|
||||
|
||||
expect(downstream.inputs[0].link).toBeNull()
|
||||
expect(downstream.onConnectionsChange).toHaveBeenCalledWith(
|
||||
LiteGraph.INPUT,
|
||||
0,
|
||||
false,
|
||||
expect.anything(),
|
||||
downstream.inputs[0]
|
||||
)
|
||||
})
|
||||
|
||||
test('ignores deferred match type refresh after the input is removed', () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
const rafCallbacks: FrameRequestCallback[] = []
|
||||
const requestAnimationFrameSpy = vi
|
||||
.spyOn(window, 'requestAnimationFrame')
|
||||
.mockImplementation((callback) => {
|
||||
rafCallbacks.push(callback)
|
||||
return rafCallbacks.length
|
||||
})
|
||||
graph.add(node)
|
||||
|
||||
try {
|
||||
addMatchType(node, 'removed')
|
||||
node.inputs.pop()
|
||||
rafCallbacks[0]?.(0)
|
||||
|
||||
expect(node.inputs).toHaveLength(0)
|
||||
} finally {
|
||||
requestAnimationFrameSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Autogrow', () => {
|
||||
const inputsSpec = { required: { image: ['IMAGE', {}] } }
|
||||
test('Can name by prefix', () => {
|
||||
@@ -499,244 +162,6 @@ describe('Autogrow', () => {
|
||||
connectInput(node, 2, graph)
|
||||
expect(node.inputs.length).toBe(3)
|
||||
})
|
||||
|
||||
test('ignores autogrow notifications that cannot affect a known input group', () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
graph.add(node)
|
||||
addAutogrow(node, { min: 1, input: inputsSpec, prefix: 'test' })
|
||||
const inputCount = node.inputs.length
|
||||
const unknownInput = node.addInput('outside.0', 'IMAGE')
|
||||
|
||||
node.onConnectionsChange?.(LiteGraph.OUTPUT, 0, true, null, node.inputs[0])
|
||||
node.onConnectionsChange?.(LiteGraph.INPUT, 99, true, null, node.inputs[0])
|
||||
node.onConnectionsChange?.(LiteGraph.INPUT, 2, true, null, unknownInput)
|
||||
|
||||
expect(node.inputs).toHaveLength(inputCount + 1)
|
||||
})
|
||||
|
||||
test('does not grow autogrow inputs when connection metadata is missing', () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
graph.add(node)
|
||||
addAutogrow(node, { min: 1, input: inputsSpec, prefix: 'test' })
|
||||
|
||||
node.onConnectionsChange?.(LiteGraph.INPUT, 1, true, null, node.inputs[1])
|
||||
|
||||
expect(node.inputs).toHaveLength(2)
|
||||
})
|
||||
|
||||
test('keeps minimum autogrow rows when disconnecting early ordinals', async () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
graph.add(node)
|
||||
addAutogrow(node, { min: 2, input: inputsSpec, prefix: 'test' })
|
||||
|
||||
node.onConnectionsChange?.(LiteGraph.INPUT, 0, false, null, node.inputs[0])
|
||||
await nextTick()
|
||||
|
||||
expect(node.inputs).toHaveLength(3)
|
||||
})
|
||||
|
||||
test('restores a configure-time autogrow widget shim', () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
graph.add(node)
|
||||
addAutogrow(node, { min: 1, input: inputsSpec, prefix: 'test' })
|
||||
node.inputs[1].widget = { name: node.inputs[1].name }
|
||||
mockConfiguringGraph()
|
||||
|
||||
connectInput(node, 1, graph)
|
||||
|
||||
expect(node.widgets.some((widget) => widget.name === '0.test1')).toBe(true)
|
||||
})
|
||||
|
||||
test('draws configure-time autogrow shim text from the input name', () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
graph.add(node)
|
||||
addAutogrow(node, { min: 1, input: inputsSpec, prefix: 'test' })
|
||||
node.inputs[1].widget = { name: node.inputs[1].name }
|
||||
mockConfiguringGraph()
|
||||
|
||||
connectInput(node, 1, graph)
|
||||
const shim = node.widgets.find((widget) => widget.name === '0.test1')
|
||||
if (!shim?.draw) throw new Error('Missing shim widget')
|
||||
node.inputs[1].label = undefined
|
||||
const ctx = fromPartial<CanvasRenderingContext2D>({
|
||||
save: vi.fn(),
|
||||
fillText: vi.fn(),
|
||||
restore: vi.fn()
|
||||
})
|
||||
|
||||
shim.draw(ctx, node, 100, 10, 20)
|
||||
|
||||
expect(ctx.fillText).toHaveBeenCalledWith('0.test1', 20, 25)
|
||||
})
|
||||
|
||||
test('keeps an existing configure-time autogrow widget shim', () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
graph.add(node)
|
||||
addAutogrow(node, { min: 1, input: inputsSpec, prefix: 'test' })
|
||||
node.inputs[1].widget = { name: node.inputs[1].name }
|
||||
node.widgets.push({
|
||||
name: node.inputs[1].name,
|
||||
type: 'shim',
|
||||
y: 0,
|
||||
options: {},
|
||||
serialize: false,
|
||||
draw: vi.fn()
|
||||
})
|
||||
mockConfiguringGraph()
|
||||
|
||||
connectInput(node, 1, graph)
|
||||
|
||||
expect(
|
||||
node.widgets.filter((widget) => widget.name === '0.test1')
|
||||
).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('defers disconnect handling during an input swap', () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
const rafCallbacks: FrameRequestCallback[] = []
|
||||
const requestAnimationFrameSpy = vi
|
||||
.spyOn(window, 'requestAnimationFrame')
|
||||
.mockImplementation((callback) => {
|
||||
rafCallbacks.push(callback)
|
||||
return rafCallbacks.length
|
||||
})
|
||||
graph.add(node)
|
||||
addAutogrow(node, { min: 1, input: inputsSpec, prefix: 'test' })
|
||||
|
||||
try {
|
||||
connectInput(node, 0, graph)
|
||||
node.disconnectInput(0)
|
||||
|
||||
expect(node.inputs).toHaveLength(2)
|
||||
expect(rafCallbacks).toHaveLength(2)
|
||||
} finally {
|
||||
requestAnimationFrameSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test('stops cleanup for uneven multi-input autogrow groups', async () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined)
|
||||
graph.add(node)
|
||||
addAutogrow(node, {
|
||||
min: 1,
|
||||
input: { required: { image: ['IMAGE', {}], mask: ['MASK', {}] } }
|
||||
})
|
||||
node.inputs.pop()
|
||||
|
||||
try {
|
||||
node.onConnectionsChange?.(
|
||||
LiteGraph.INPUT,
|
||||
0,
|
||||
false,
|
||||
null,
|
||||
node.inputs[0]
|
||||
)
|
||||
await nextTick()
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Failed to group multi-input autogrow inputs'
|
||||
)
|
||||
} finally {
|
||||
consoleErrorSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test('keeps trailing autogrow row when disconnecting the last slot', async () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
graph.add(node)
|
||||
addAutogrow(node, { min: 1, input: inputsSpec, prefix: 'test' })
|
||||
|
||||
node.onConnectionsChange?.(LiteGraph.INPUT, 1, false, null, node.inputs[1])
|
||||
await nextTick()
|
||||
|
||||
expect(node.inputs.map((input) => input.name)).toEqual([
|
||||
'0.test0',
|
||||
'0.test1'
|
||||
])
|
||||
})
|
||||
|
||||
test('ignores named autogrow input names outside the configured list', async () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
graph.add(node)
|
||||
addAutogrow(node, { min: 1, input: inputsSpec, names: ['a', 'b'] })
|
||||
const unknownInput = node.addInput('0.c', 'IMAGE')
|
||||
|
||||
node.onConnectionsChange?.(
|
||||
LiteGraph.INPUT,
|
||||
node.inputs.length - 1,
|
||||
false,
|
||||
null,
|
||||
unknownInput
|
||||
)
|
||||
await nextTick()
|
||||
|
||||
expect(node.inputs.map((input) => input.name)).toEqual([
|
||||
'0.a',
|
||||
'0.b',
|
||||
'0.c'
|
||||
])
|
||||
})
|
||||
|
||||
test('ignores autogrow input names without numeric ordinals', async () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
graph.add(node)
|
||||
addAutogrow(node, { min: 1, input: inputsSpec, prefix: 'test' })
|
||||
const unknownInput = node.addInput('0.testx', 'IMAGE')
|
||||
|
||||
node.onConnectionsChange?.(
|
||||
LiteGraph.INPUT,
|
||||
node.inputs.length - 1,
|
||||
false,
|
||||
null,
|
||||
unknownInput
|
||||
)
|
||||
await nextTick()
|
||||
|
||||
expect(node.inputs.map((input) => input.name)).toEqual([
|
||||
'0.test0',
|
||||
'0.test1',
|
||||
'0.testx'
|
||||
])
|
||||
})
|
||||
|
||||
test('marks optional autogrow inputs as optional after required inputs', () => {
|
||||
const node = testNode()
|
||||
|
||||
addAutogrow(node, {
|
||||
min: 1,
|
||||
input: {
|
||||
required: { image: ['IMAGE', {}] },
|
||||
optional: { mask: ['MASK', {}] }
|
||||
}
|
||||
})
|
||||
|
||||
expect(node.inputs.map((input) => input.name)).toEqual([
|
||||
'0.image0',
|
||||
'0.mask0',
|
||||
'0.image1',
|
||||
'0.mask1'
|
||||
])
|
||||
expect(node.inputs.map((input) => input.type)).toEqual([
|
||||
'IMAGE',
|
||||
'MASK',
|
||||
'IMAGE',
|
||||
'MASK'
|
||||
])
|
||||
})
|
||||
test('Removing connections decreases to min + 1', async () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
@@ -833,42 +258,6 @@ describe('Autogrow', () => {
|
||||
expect(vid0Link).not.toBeNull()
|
||||
expect(graph.links[vid0Link!].target_slot).toBe(vid0Index)
|
||||
})
|
||||
|
||||
test('removes shim widgets when multi-input autogrow rows shrink', async () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
graph.add(node)
|
||||
addAutogrow(node, {
|
||||
min: 1,
|
||||
input: { required: { image: ['IMAGE', {}], mask: ['MASK', {}] } }
|
||||
})
|
||||
connectInput(node, 2, graph)
|
||||
await nextTick()
|
||||
expect(node.inputs).toHaveLength(6)
|
||||
|
||||
const removedWidgetNames = ['0.image2', '0.mask2']
|
||||
const onRemove = vi.fn()
|
||||
for (const widget of node.widgets.filter((widget) =>
|
||||
removedWidgetNames.includes(widget.name)
|
||||
)) {
|
||||
widget.onRemove = onRemove
|
||||
}
|
||||
|
||||
node.disconnectInput(2)
|
||||
await nextTick()
|
||||
|
||||
expect(node.inputs.map((input) => input.name)).toEqual([
|
||||
'0.image0',
|
||||
'0.mask0',
|
||||
'0.image1',
|
||||
'0.mask1'
|
||||
])
|
||||
expect(onRemove).toHaveBeenCalledTimes(2)
|
||||
expect(
|
||||
node.widgets.some((widget) => removedWidgetNames.includes(widget.name))
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
test('Can deserialize a complex node', async () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
|
||||
@@ -53,7 +54,6 @@ function createSourceNode(graph: LGraph, type: string) {
|
||||
|
||||
describe('MatchType during configure', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
@@ -73,22 +73,30 @@ describe('MatchType during configure', () => {
|
||||
const link2Id = switchNode.inputs[1].link!
|
||||
|
||||
const outputTypeBefore = switchNode.outputs[0].type
|
||||
vi.spyOn(app, 'configuringGraph', 'get').mockReturnValue(true)
|
||||
fromAny<{ configuringGraphLevel: number }, unknown>(
|
||||
app
|
||||
).configuringGraphLevel = 1
|
||||
|
||||
const link1 = graph.links[link1Id]
|
||||
switchNode.onConnectionsChange?.(
|
||||
LiteGraph.INPUT,
|
||||
0,
|
||||
true,
|
||||
link1,
|
||||
switchNode.inputs[0]
|
||||
)
|
||||
try {
|
||||
const link1 = graph.links[link1Id]
|
||||
switchNode.onConnectionsChange?.(
|
||||
LiteGraph.INPUT,
|
||||
0,
|
||||
true,
|
||||
link1,
|
||||
switchNode.inputs[0]
|
||||
)
|
||||
|
||||
expect(switchNode.inputs[0].link).toBe(link1Id)
|
||||
expect(switchNode.inputs[1].link).toBe(link2Id)
|
||||
expect(graph.links[link1Id]).toBeDefined()
|
||||
expect(graph.links[link2Id]).toBeDefined()
|
||||
expect(switchNode.outputs[0].type).toBe(outputTypeBefore)
|
||||
expect(switchNode.inputs[0].link).toBe(link1Id)
|
||||
expect(switchNode.inputs[1].link).toBe(link2Id)
|
||||
expect(graph.links[link1Id]).toBeDefined()
|
||||
expect(graph.links[link2Id]).toBeDefined()
|
||||
expect(switchNode.outputs[0].type).toBe(outputTypeBefore)
|
||||
} finally {
|
||||
fromAny<{ configuringGraphLevel: number }, unknown>(
|
||||
app
|
||||
).configuringGraphLevel = 0
|
||||
}
|
||||
})
|
||||
|
||||
test('performs type recalculation during normal operation', () => {
|
||||
@@ -119,45 +127,4 @@ describe('MatchType during configure', () => {
|
||||
expect(switchNode.inputs[1].link).not.toBeNull()
|
||||
expect(switchNode.outputs[0].type).toBe('IMAGE')
|
||||
})
|
||||
|
||||
test('keeps compatible downstream links after output type recalculation', () => {
|
||||
const graph = new LGraph()
|
||||
const switchNode = createMatchTypeNode(graph)
|
||||
const target = new LGraphNode('target')
|
||||
target.addInput('image', 'IMAGE')
|
||||
target.onConnectionsChange = vi.fn()
|
||||
graph.add(target)
|
||||
const source = createSourceNode(graph, 'IMAGE')
|
||||
|
||||
switchNode.connect(0, target, 0)
|
||||
vi.mocked(target.onConnectionsChange).mockClear()
|
||||
source.connect(0, switchNode, 0)
|
||||
|
||||
expect(switchNode.outputs[0].type).toBe('IMAGE')
|
||||
expect(target.inputs[0].link).not.toBeNull()
|
||||
expect(target.onConnectionsChange).toHaveBeenCalledWith(
|
||||
LiteGraph.INPUT,
|
||||
0,
|
||||
true,
|
||||
expect.anything(),
|
||||
target.inputs[0]
|
||||
)
|
||||
})
|
||||
|
||||
test('disconnects incompatible downstream links after output type recalculation', () => {
|
||||
const graph = new LGraph()
|
||||
const switchNode = createMatchTypeNode(graph)
|
||||
const target = new LGraphNode('target')
|
||||
target.addInput('image', 'IMAGE')
|
||||
graph.add(target)
|
||||
const source = createSourceNode(graph, 'LATENT')
|
||||
|
||||
switchNode.connect(0, target, 0)
|
||||
expect(target.inputs[0].link).not.toBeNull()
|
||||
|
||||
source.connect(0, switchNode, 0)
|
||||
|
||||
expect(switchNode.outputs[0].type).toBe('LATENT')
|
||||
expect(target.inputs[0].link).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,47 +1,14 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { SerialisedLLinkArray } from '@/lib/litegraph/src/LLink'
|
||||
import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { ComfyNode } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
import type { ComfyApp } from '@/scripts/app'
|
||||
import type { ComfyExtension } from '@/types/comfy'
|
||||
|
||||
import type { GroupNodeWorkflowData } from './groupNode'
|
||||
|
||||
const appMock = vi.hoisted(() => ({
|
||||
canvas: {
|
||||
emitAfterChange: vi.fn(),
|
||||
emitBeforeChange: vi.fn(),
|
||||
selected_nodes: {}
|
||||
},
|
||||
registerExtension: vi.fn(),
|
||||
registerNodeDef: vi.fn(),
|
||||
rootGraph: {
|
||||
convertToSubgraph: vi.fn(),
|
||||
extra: {},
|
||||
getNodeById: vi.fn(),
|
||||
links: {},
|
||||
nodes: [],
|
||||
remove: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
const widgetStoreMock = vi.hoisted(() => ({
|
||||
inputIsWidget: vi.fn((spec: unknown[]) =>
|
||||
['BOOLEAN', 'COMBO', 'FLOAT', 'INT', 'STRING'].includes(String(spec[0]))
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: appMock
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/widgetStore', () => ({
|
||||
useWidgetStore: () => widgetStoreMock
|
||||
app: {
|
||||
registerExtension: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
import { GroupNodeConfig, replaceLegacySeparators } from './groupNode'
|
||||
@@ -59,46 +26,6 @@ function makeNode(type: string): ComfyNode {
|
||||
}
|
||||
}
|
||||
|
||||
function makeNodeDef(overrides: Partial<ComfyNodeDef> = {}): ComfyNodeDef {
|
||||
return {
|
||||
name: 'TestNode',
|
||||
display_name: 'Test Node',
|
||||
description: '',
|
||||
category: 'test',
|
||||
input: { required: {}, optional: {} },
|
||||
output: [],
|
||||
output_name: [],
|
||||
output_is_list: [],
|
||||
output_node: false,
|
||||
python_module: 'test',
|
||||
...overrides
|
||||
} as ComfyNodeDef
|
||||
}
|
||||
|
||||
function extension(): ComfyExtension {
|
||||
const groupExtension = appMock.registerExtension.mock.calls.find(
|
||||
([registered]) => registered.name === 'Comfy.GroupNode'
|
||||
)?.[0]
|
||||
if (!groupExtension) throw new Error('GroupNode extension was not registered')
|
||||
return groupExtension as ComfyExtension
|
||||
}
|
||||
|
||||
function addCustomNodeDefs(defs: Record<string, ComfyNodeDef>) {
|
||||
const groupExtension = extension()
|
||||
if (!groupExtension.addCustomNodeDefs) {
|
||||
throw new Error('GroupNode extension does not implement addCustomNodeDefs')
|
||||
}
|
||||
groupExtension.addCustomNodeDefs(defs, fromPartial<ComfyApp>(appMock))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
appMock.registerNodeDef.mockReset()
|
||||
widgetStoreMock.inputIsWidget.mockClear()
|
||||
LiteGraph.registered_node_types = {}
|
||||
addCustomNodeDefs({})
|
||||
})
|
||||
|
||||
describe('replaceLegacySeparators', () => {
|
||||
it('rewrites the legacy "workflow/" prefix to "workflow>"', () => {
|
||||
const nodes = [makeNode('workflow/My Group')]
|
||||
@@ -122,7 +49,7 @@ describe('replaceLegacySeparators', () => {
|
||||
describe('GroupNodeConfig.getLinks', () => {
|
||||
function configFrom(
|
||||
links: SerialisedLLinkArray[],
|
||||
external: (number | string | null)[][] = []
|
||||
external: (number | string)[][] = []
|
||||
) {
|
||||
const nodeData: GroupNodeWorkflowData = {
|
||||
nodes: [
|
||||
@@ -177,390 +104,4 @@ describe('GroupNodeConfig.getLinks', () => {
|
||||
const config = configFrom([], [[0, 1, 'IMAGE']])
|
||||
expect(config.externalFrom[0][1]).toBe('IMAGE')
|
||||
})
|
||||
|
||||
it('ignores external links without a type and accumulates multiple slots', () => {
|
||||
const config = configFrom(
|
||||
[],
|
||||
[
|
||||
[0, 1, null],
|
||||
[0, 2, 'LATENT'],
|
||||
[0, 3, 'IMAGE']
|
||||
]
|
||||
)
|
||||
|
||||
expect(config.externalFrom[0]).toEqual({ 2: 'LATENT', 3: 'IMAGE' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('GroupNodeConfig.getNodeDef', () => {
|
||||
const imageNodeDef = makeNodeDef({
|
||||
name: 'ImageNode',
|
||||
input: {
|
||||
required: {
|
||||
image: ['IMAGE', {}],
|
||||
mode: [['fast', 'slow'], {}]
|
||||
},
|
||||
optional: {
|
||||
strength: ['FLOAT', { default: 1 }]
|
||||
}
|
||||
},
|
||||
output: ['IMAGE'],
|
||||
output_name: ['image'],
|
||||
output_is_list: [false]
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
addCustomNodeDefs({ ImageNode: imageNodeDef })
|
||||
})
|
||||
|
||||
it('returns registered definitions for normal node types', () => {
|
||||
const config = new GroupNodeConfig('group', {
|
||||
nodes: [{ index: 0, type: 'ImageNode' }],
|
||||
links: [],
|
||||
external: []
|
||||
})
|
||||
|
||||
expect(config.getNodeDef({ index: 0, type: 'ImageNode' })).toBe(
|
||||
imageNodeDef
|
||||
)
|
||||
})
|
||||
|
||||
it('returns undefined for nodes without an index or a known type', () => {
|
||||
const config = new GroupNodeConfig('group', {
|
||||
nodes: [{ type: 'UnknownNode' }],
|
||||
links: [],
|
||||
external: []
|
||||
})
|
||||
|
||||
expect(config.getNodeDef({ type: 'UnknownNode' })).toBeUndefined()
|
||||
})
|
||||
|
||||
it('skips unlinked primitive nodes', () => {
|
||||
const config = new GroupNodeConfig('group', {
|
||||
nodes: [{ index: 0, type: 'PrimitiveNode' }],
|
||||
links: [],
|
||||
external: []
|
||||
})
|
||||
|
||||
expect(
|
||||
config.getNodeDef({ index: 0, type: 'PrimitiveNode' })
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('derives primitive node type from the outgoing link type', () => {
|
||||
const config = new GroupNodeConfig('group', {
|
||||
nodes: [
|
||||
{ index: 0, type: 'PrimitiveNode' },
|
||||
{ index: 1, type: 'ImageNode' }
|
||||
],
|
||||
links: [[0, 0, 1, 0, 1, 'IMAGE'] as SerialisedLLinkArray],
|
||||
external: []
|
||||
})
|
||||
|
||||
expect(
|
||||
config.getNodeDef({ index: 0, type: 'PrimitiveNode' })
|
||||
).toMatchObject({
|
||||
input: { required: { value: ['IMAGE', {}] } },
|
||||
output: ['IMAGE']
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to null when primitive combo target spec is not primitive', () => {
|
||||
const config = new GroupNodeConfig('group', {
|
||||
nodes: [
|
||||
{
|
||||
index: 0,
|
||||
type: 'PrimitiveNode',
|
||||
outputs: [{ name: 'mode', widget: { name: 'mode' } }]
|
||||
},
|
||||
{ index: 1, type: 'ImageNode' }
|
||||
],
|
||||
links: [[0, 0, 1, 0, 1, 'COMBO'] as SerialisedLLinkArray],
|
||||
external: []
|
||||
})
|
||||
|
||||
expect(config.getNodeDef(config.nodeData.nodes[0])).toMatchObject({
|
||||
input: { required: { value: [null, {}] } },
|
||||
output: [null]
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null for reroutes used only inside the group', () => {
|
||||
const config = new GroupNodeConfig('group', {
|
||||
nodes: [
|
||||
{ index: 0, type: 'ImageNode' },
|
||||
{ index: 1, type: 'Reroute' },
|
||||
{ index: 2, type: 'ImageNode' }
|
||||
],
|
||||
links: [
|
||||
[0, 0, 1, 0, 1, 'IMAGE'],
|
||||
[1, 0, 2, 0, 2, 'IMAGE']
|
||||
] as SerialisedLLinkArray[],
|
||||
external: []
|
||||
})
|
||||
|
||||
expect(config.getNodeDef({ index: 1, type: 'Reroute' })).toBeNull()
|
||||
})
|
||||
|
||||
it('derives reroute type from outgoing target inputs', () => {
|
||||
const config = new GroupNodeConfig('group', {
|
||||
nodes: [
|
||||
{ index: 0, type: 'Reroute' },
|
||||
{
|
||||
index: 1,
|
||||
type: 'ImageNode',
|
||||
inputs: [{ name: 'image', type: 'IMAGE' }]
|
||||
}
|
||||
],
|
||||
links: [[0, 0, 1, 0, 1, 'IMAGE'] as SerialisedLLinkArray],
|
||||
external: [[0, 0, 'IMAGE']]
|
||||
})
|
||||
|
||||
expect(config.getNodeDef({ index: 0, type: 'Reroute' })).toMatchObject({
|
||||
input: { required: { IMAGE: ['IMAGE', { forceInput: true }] } },
|
||||
output: ['IMAGE']
|
||||
})
|
||||
})
|
||||
|
||||
it('derives reroute type from incoming output metadata', () => {
|
||||
const config = new GroupNodeConfig('group', {
|
||||
nodes: [
|
||||
{ index: 0, type: 'ImageNode', outputs: [{ type: 'LATENT' }] },
|
||||
{ index: 1, type: 'Reroute' }
|
||||
],
|
||||
links: [[0, 0, 1, 0, 1, 'LATENT'] as SerialisedLLinkArray],
|
||||
external: [[1, 0, 'LATENT']]
|
||||
})
|
||||
|
||||
expect(config.getNodeDef({ index: 1, type: 'Reroute' })).toMatchObject({
|
||||
input: { required: { LATENT: ['LATENT', { forceInput: true }] } },
|
||||
output: ['LATENT']
|
||||
})
|
||||
})
|
||||
|
||||
it('derives pipe reroute type from external metadata when links omit it', () => {
|
||||
const config = new GroupNodeConfig('group', {
|
||||
nodes: [{ index: 0, type: 'Reroute' }],
|
||||
links: [],
|
||||
external: [[0, 0, 'MASK']]
|
||||
})
|
||||
|
||||
expect(config.getNodeDef({ index: 0, type: 'Reroute' })).toMatchObject({
|
||||
input: { required: { MASK: ['MASK', { forceInput: true }] } },
|
||||
output: ['MASK']
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('GroupNodeConfig input and output mapping', () => {
|
||||
function configWithNode(node: GroupNodeWorkflowData['nodes'][number]) {
|
||||
const config = new GroupNodeConfig('group', {
|
||||
nodes: [node],
|
||||
links: [],
|
||||
external: [],
|
||||
config: {
|
||||
0: {
|
||||
input: {
|
||||
hidden: { visible: false },
|
||||
renamed: { name: 'Custom Name' }
|
||||
},
|
||||
output: {
|
||||
1: { name: 'Custom Output' },
|
||||
2: { visible: false }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
config.nodeDef = makeNodeDef({
|
||||
input: { required: {} },
|
||||
output: [],
|
||||
output_name: [],
|
||||
output_is_list: []
|
||||
})
|
||||
return config
|
||||
}
|
||||
|
||||
it('renames duplicate inputs and adds seed control metadata', () => {
|
||||
const config = configWithNode({
|
||||
index: 0,
|
||||
type: 'Sampler',
|
||||
title: 'Sampler A',
|
||||
inputs: [{ name: 'seed', label: 'Seed Label' }]
|
||||
})
|
||||
const seenInputs = { seed: 1, 'Sampler A seed': 1 }
|
||||
const result = config.getInputConfig(
|
||||
{ index: 0, type: 'Sampler', title: 'Sampler A' },
|
||||
'seed',
|
||||
seenInputs,
|
||||
['INT', {}]
|
||||
)
|
||||
|
||||
expect(result.name).toBe('Sampler A 1 seed')
|
||||
expect(result.config).toEqual([
|
||||
'INT',
|
||||
{ control_after_generate: 'Sampler A control_after_generate' }
|
||||
])
|
||||
})
|
||||
|
||||
it('maps image upload widget aliases through converted widget names', () => {
|
||||
const config = configWithNode({ index: 0, type: 'LoadImage' })
|
||||
config.oldToNewWidgetMap[0] = { customImage: 'Uploaded Image' }
|
||||
|
||||
expect(
|
||||
config.getInputConfig({ index: 0, type: 'LoadImage' }, 'renamed', {}, [
|
||||
'IMAGEUPLOAD',
|
||||
{ widget: 'customImage' }
|
||||
])
|
||||
).toMatchObject({
|
||||
name: 'Custom Name',
|
||||
config: ['IMAGEUPLOAD', { widget: 'Uploaded Image' }]
|
||||
})
|
||||
})
|
||||
|
||||
it('splits widget inputs, socket inputs, and converted widget slots', () => {
|
||||
const config = configWithNode({
|
||||
index: 0,
|
||||
type: 'MixedNode',
|
||||
inputs: [{ name: 'mode', widget: { name: 'mode' } }]
|
||||
})
|
||||
|
||||
const result = config.processWidgetInputs(
|
||||
{
|
||||
mode: ['COMBO', {}],
|
||||
image: ['IMAGE', {}]
|
||||
},
|
||||
{
|
||||
index: 0,
|
||||
type: 'MixedNode',
|
||||
inputs: [{ name: 'mode', widget: { name: 'mode' } }]
|
||||
},
|
||||
['mode', 'image'],
|
||||
{}
|
||||
)
|
||||
|
||||
expect(result.slots).toEqual(['image'])
|
||||
expect(result.converted.get(0)).toBe('mode')
|
||||
expect(config.oldToNewWidgetMap[0].mode).toBeNull()
|
||||
})
|
||||
|
||||
it('adds visible unlinked input slots and skips hidden configured inputs', () => {
|
||||
const config = configWithNode({
|
||||
index: 0,
|
||||
type: 'InputNode'
|
||||
})
|
||||
const inputMap: Record<number, number> = {}
|
||||
config.processInputSlots(
|
||||
{
|
||||
image: ['IMAGE', {}],
|
||||
hidden: ['LATENT', {}]
|
||||
},
|
||||
{ index: 0, type: 'InputNode' },
|
||||
['image', 'hidden'],
|
||||
{},
|
||||
inputMap,
|
||||
{}
|
||||
)
|
||||
|
||||
expect(config.nodeDef?.input?.required).toEqual({ image: ['IMAGE', {}] })
|
||||
expect(inputMap).toEqual({ 0: 0 })
|
||||
})
|
||||
|
||||
it('adds output metadata, hides linked/internal outputs, and dedupes labels', () => {
|
||||
const config = configWithNode({
|
||||
index: 0,
|
||||
type: 'OutputNode',
|
||||
title: 'Output A',
|
||||
outputs: [{ name: 'image', label: 'Rendered' }]
|
||||
})
|
||||
config.linksFrom[0] = {
|
||||
0: [[0, 0, 1, 0, 1, 'IMAGE'] as SerialisedLLinkArray]
|
||||
}
|
||||
config.processNodeOutputs(
|
||||
{ index: 0, type: 'OutputNode', title: 'Output A' },
|
||||
{ Rendered: 1 },
|
||||
{
|
||||
input: { required: {} },
|
||||
output: ['IMAGE', 'LATENT', 'MASK'],
|
||||
output_name: ['image', 'latent', 'mask'],
|
||||
output_is_list: [false, true, false]
|
||||
}
|
||||
)
|
||||
|
||||
expect(config.outputVisibility).toEqual([false, true, false])
|
||||
expect(config.nodeDef?.output).toEqual(['LATENT'])
|
||||
expect(config.nodeDef?.output_is_list).toEqual([true])
|
||||
expect(config.nodeDef?.output_name).toEqual(['Custom Output'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('GroupNodeConfig.registerFromWorkflow', () => {
|
||||
it('adds missing type actions and skips registration for incomplete groups', async () => {
|
||||
const groupNodes: Record<string, GroupNodeWorkflowData> = {
|
||||
Broken: {
|
||||
nodes: [{ index: 0, type: 'MissingNode' }],
|
||||
links: [],
|
||||
external: []
|
||||
}
|
||||
}
|
||||
const missingNodeTypes: Parameters<
|
||||
typeof GroupNodeConfig.registerFromWorkflow
|
||||
>[1] = []
|
||||
|
||||
await GroupNodeConfig.registerFromWorkflow(groupNodes, missingNodeTypes)
|
||||
|
||||
expect(appMock.registerNodeDef).not.toHaveBeenCalled()
|
||||
expect(missingNodeTypes).toHaveLength(2)
|
||||
expect(missingNodeTypes[0]).toMatchObject({
|
||||
type: 'MissingNode',
|
||||
hint: " (In group node 'workflow>Broken')"
|
||||
})
|
||||
|
||||
const action = missingNodeTypes[1]
|
||||
if (typeof action === 'string') {
|
||||
throw new Error('Expected an action entry for the broken group node')
|
||||
}
|
||||
const target = document.createElement('button')
|
||||
const { callback } = action.action as {
|
||||
callback: (event: MouseEvent) => void
|
||||
}
|
||||
const event = new MouseEvent('click')
|
||||
Object.defineProperty(event, 'target', { value: target })
|
||||
callback(event)
|
||||
expect(groupNodes.Broken).toBeUndefined()
|
||||
expect(target.textContent).toBe('Removed')
|
||||
expect(target.style.pointerEvents).toBe('none')
|
||||
})
|
||||
|
||||
it('registers complete group node types and stores their generated node defs', async () => {
|
||||
addCustomNodeDefs({
|
||||
ImageNode: makeNodeDef({
|
||||
name: 'ImageNode',
|
||||
input: { required: { image: ['IMAGE', {}] } },
|
||||
output: ['IMAGE'],
|
||||
output_name: ['image'],
|
||||
output_is_list: [false]
|
||||
})
|
||||
})
|
||||
LiteGraph.registered_node_types.ImageNode = class extends LGraphNode {}
|
||||
|
||||
await GroupNodeConfig.registerFromWorkflow(
|
||||
{
|
||||
Complete: {
|
||||
nodes: [{ index: 0, type: 'ImageNode' }],
|
||||
links: [],
|
||||
external: [[0, 0, 'IMAGE']]
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
expect(appMock.registerNodeDef).toHaveBeenCalledWith(
|
||||
'workflow>Complete',
|
||||
expect.objectContaining({
|
||||
category: 'group nodes>workflow',
|
||||
display_name: 'Complete',
|
||||
name: 'workflow>Complete'
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -62,7 +62,7 @@ interface GroupNodeConfigEntry {
|
||||
}
|
||||
|
||||
export interface GroupNodeWorkflowData {
|
||||
external: (number | string | null)[][]
|
||||
external: (number | string)[][]
|
||||
links: SerialisedLLinkArray[]
|
||||
nodes: {
|
||||
index?: number
|
||||
|
||||
@@ -260,6 +260,7 @@ useExtensionService().registerExtension({
|
||||
if (!isLoad3dNode(selectedNode)) return
|
||||
|
||||
ComfyApp.copyToClipspace(selectedNode)
|
||||
// @ts-expect-error clipspace_return_node is an extension property added at runtime
|
||||
ComfyApp.clipspace_return_node = selectedNode
|
||||
|
||||
const props = { node: selectedNode }
|
||||
|
||||
@@ -2494,7 +2494,6 @@ export class LGraph
|
||||
// Deprecated - old schema version, links are arrays
|
||||
if (Array.isArray(data.links)) {
|
||||
for (const linkData of data.links) {
|
||||
if (!linkData) continue
|
||||
const link = LLink.createFromArray(linkData)
|
||||
this._links.set(link.id, link)
|
||||
}
|
||||
|
||||
@@ -3799,7 +3799,7 @@ export class LGraphNode
|
||||
const title = String(rawTitle) + (this.pinned ? '📌' : '')
|
||||
if (title) {
|
||||
if (selected) {
|
||||
ctx.fillStyle = LiteGraph.NODE_SELECTED_TITLE_COLOR ?? '#FFF'
|
||||
ctx.fillStyle = LiteGraph.NODE_SELECTED_TITLE_COLOR
|
||||
} else {
|
||||
ctx.fillStyle = this.constructor.title_text_color || default_title_color
|
||||
}
|
||||
|
||||
@@ -54,10 +54,10 @@ export class LiteGraphGlobal {
|
||||
NODE_COLLAPSED_RADIUS = 10
|
||||
NODE_COLLAPSED_WIDTH = 80
|
||||
NODE_TITLE_COLOR = '#999'
|
||||
NODE_SELECTED_TITLE_COLOR: string | undefined = '#FFF'
|
||||
NODE_SELECTED_TITLE_COLOR = '#FFF'
|
||||
NODE_TEXT_SIZE = 14
|
||||
NODE_TEXT_COLOR = '#AAA'
|
||||
NODE_TEXT_HIGHLIGHT_COLOR: string | undefined = '#EEE'
|
||||
NODE_TEXT_HIGHLIGHT_COLOR = '#EEE'
|
||||
NODE_SUBTEXT_SIZE = 12
|
||||
NODE_DEFAULT_COLOR = '#333'
|
||||
NODE_DEFAULT_BGCOLOR = '#353535'
|
||||
|
||||
@@ -1,451 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
|
||||
import type {
|
||||
DefaultConnectionColors,
|
||||
INodeInputSlot,
|
||||
INodeOutputSlot
|
||||
} from '@/lib/litegraph/src/interfaces'
|
||||
import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { SlotType } from '@/lib/litegraph/src/draw'
|
||||
import {
|
||||
LinkDirection,
|
||||
RenderShape
|
||||
} from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { toLinkId } from '@/types/linkId'
|
||||
import { createMockCanvasRenderingContext2D } from '@/utils/__tests__/litegraphTestUtils'
|
||||
import { createTestSubgraph } from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
|
||||
import { NodeInputSlot } from './NodeInputSlot'
|
||||
import { NodeOutputSlot } from './NodeOutputSlot'
|
||||
|
||||
function createColorContext(): DefaultConnectionColors {
|
||||
return {
|
||||
getConnectedColor: vi.fn(() => '#0f0'),
|
||||
getDisconnectedColor: vi.fn(() => '#f00')
|
||||
}
|
||||
}
|
||||
|
||||
function createNode(): LGraphNode {
|
||||
const node = new LGraphNode('Test Node')
|
||||
node.pos = [0, 0]
|
||||
return node
|
||||
}
|
||||
|
||||
function createInputSlot(
|
||||
overrides: Partial<INodeInputSlot> = {},
|
||||
node = createNode()
|
||||
): NodeInputSlot {
|
||||
return new NodeInputSlot(
|
||||
{
|
||||
name: 'in',
|
||||
type: 'STRING',
|
||||
link: null,
|
||||
boundingRect: [10, 20, 20, 20] as const,
|
||||
...overrides
|
||||
},
|
||||
node
|
||||
)
|
||||
}
|
||||
|
||||
function createOutputSlot(
|
||||
overrides: Partial<INodeOutputSlot> = {},
|
||||
node = createNode()
|
||||
): NodeOutputSlot {
|
||||
return new NodeOutputSlot(
|
||||
{
|
||||
name: 'out',
|
||||
type: 'STRING',
|
||||
links: null,
|
||||
boundingRect: [10, 20, 20, 20] as const,
|
||||
...overrides
|
||||
},
|
||||
node
|
||||
)
|
||||
}
|
||||
|
||||
describe('NodeSlot rendering', () => {
|
||||
let ctx: CanvasRenderingContext2D
|
||||
let colorContext: DefaultConnectionColors
|
||||
|
||||
beforeEach(() => {
|
||||
ctx = createMockCanvasRenderingContext2D()
|
||||
colorContext = createColorContext()
|
||||
})
|
||||
|
||||
describe('draw', () => {
|
||||
it('draws a disconnected circle slot with its label', () => {
|
||||
const slot = createInputSlot()
|
||||
|
||||
slot.draw(ctx, { colorContext })
|
||||
|
||||
expect(colorContext.getDisconnectedColor).toHaveBeenCalledWith('STRING')
|
||||
expect(ctx.arc).toHaveBeenCalledWith(
|
||||
expect.any(Number),
|
||||
expect.any(Number),
|
||||
4,
|
||||
0,
|
||||
Math.PI * 2
|
||||
)
|
||||
expect(ctx.fill).toHaveBeenCalled()
|
||||
expect(ctx.fillText).toHaveBeenCalledWith(
|
||||
'in',
|
||||
expect.any(Number),
|
||||
expect.any(Number)
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the connected colour and a larger radius when highlighted', () => {
|
||||
const slot = createInputSlot({ link: toLinkId(1) })
|
||||
|
||||
slot.draw(ctx, { colorContext, highlight: true })
|
||||
|
||||
expect(colorContext.getConnectedColor).toHaveBeenCalledWith('STRING')
|
||||
expect(ctx.arc).toHaveBeenCalledWith(
|
||||
expect.any(Number),
|
||||
expect.any(Number),
|
||||
5,
|
||||
0,
|
||||
Math.PI * 2
|
||||
)
|
||||
})
|
||||
|
||||
it('prefers color_on over the colour context when connected', () => {
|
||||
const slot = createInputSlot({ link: toLinkId(1), color_on: '#abc' })
|
||||
let fillStyleAtFill: typeof ctx.fillStyle | undefined
|
||||
vi.mocked(ctx.fill).mockImplementation(() => {
|
||||
fillStyleAtFill = ctx.fillStyle
|
||||
})
|
||||
|
||||
slot.draw(ctx, { colorContext })
|
||||
|
||||
expect(fillStyleAtFill).toBe('#abc')
|
||||
expect(ctx.fillStyle).not.toBe('#abc') // restored after draw
|
||||
})
|
||||
|
||||
it('draws a box for event slots', () => {
|
||||
const slot = createInputSlot({ type: SlotType.Event })
|
||||
|
||||
slot.draw(ctx, { colorContext })
|
||||
|
||||
expect(ctx.rect).toHaveBeenCalledTimes(1)
|
||||
expect(ctx.arc).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('draws a box for box-shaped slots', () => {
|
||||
const slot = createInputSlot({ shape: RenderShape.BOX })
|
||||
|
||||
slot.draw(ctx, { colorContext })
|
||||
|
||||
expect(ctx.rect).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('draws a triangle for arrow-shaped slots', () => {
|
||||
const slot = createInputSlot({ shape: RenderShape.ARROW })
|
||||
|
||||
slot.draw(ctx, { colorContext })
|
||||
|
||||
expect(ctx.moveTo).toHaveBeenCalledTimes(1)
|
||||
expect(ctx.lineTo).toHaveBeenCalledTimes(2)
|
||||
expect(ctx.closePath).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('draws a 3x3 grid for array-typed slots', () => {
|
||||
const slot = createInputSlot({ type: SlotType.Array })
|
||||
|
||||
slot.draw(ctx, { colorContext })
|
||||
|
||||
expect(ctx.rect).toHaveBeenCalledTimes(9)
|
||||
})
|
||||
|
||||
it('draws a simple rect and no label in low quality mode', () => {
|
||||
const slot = createInputSlot()
|
||||
|
||||
slot.draw(ctx, { colorContext, lowQuality: true })
|
||||
|
||||
expect(ctx.rect).toHaveBeenCalledTimes(1)
|
||||
expect(ctx.fillText).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clips hollow circle slots to a ring', () => {
|
||||
const arc = vi.fn()
|
||||
vi.stubGlobal(
|
||||
'Path2D',
|
||||
class {
|
||||
arc = arc
|
||||
}
|
||||
)
|
||||
try {
|
||||
const slot = createInputSlot({ shape: RenderShape.HollowCircle })
|
||||
|
||||
slot.draw(ctx, { colorContext, highlight: true })
|
||||
slot.draw(ctx, { colorContext })
|
||||
|
||||
expect(ctx.clip).toHaveBeenCalledTimes(2)
|
||||
// Inner radius is larger while highlighted.
|
||||
expect(arc).toHaveBeenCalledWith(
|
||||
expect.any(Number),
|
||||
expect.any(Number),
|
||||
2.5,
|
||||
0,
|
||||
Math.PI * 2
|
||||
)
|
||||
expect(arc).toHaveBeenCalledWith(
|
||||
expect.any(Number),
|
||||
expect.any(Number),
|
||||
1.5,
|
||||
0,
|
||||
Math.PI * 2
|
||||
)
|
||||
} finally {
|
||||
vi.unstubAllGlobals()
|
||||
}
|
||||
})
|
||||
|
||||
it('draws one pie segment per type for multi-type slots', () => {
|
||||
const slot = createInputSlot({ type: 'STRING,INT' })
|
||||
|
||||
slot.draw(ctx, { colorContext })
|
||||
|
||||
// Once for the base slot colour, then once per type in the pie.
|
||||
expect(colorContext.getDisconnectedColor).toHaveBeenCalledTimes(3)
|
||||
// One filled arc per type, plus the final outline arc.
|
||||
expect(ctx.arc).toHaveBeenCalledTimes(3)
|
||||
expect(ctx.stroke).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hides the label for widget input slots', () => {
|
||||
const slot = createInputSlot({ widget: { name: 'in' } })
|
||||
|
||||
slot.draw(ctx, { colorContext })
|
||||
|
||||
expect(ctx.fillText).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips the label when there is no text to render', () => {
|
||||
const slot = createInputSlot({ name: '' })
|
||||
|
||||
slot.draw(ctx, { colorContext })
|
||||
|
||||
expect(ctx.fillText).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('draws input labels above the slot when directed up', () => {
|
||||
const slot = createInputSlot({ dir: LinkDirection.UP })
|
||||
|
||||
slot.draw(ctx, { colorContext })
|
||||
|
||||
const [, x, y] = vi.mocked(ctx.fillText).mock.calls[0]
|
||||
const slotCentre = [
|
||||
slot.boundingRect[0] + 10 - slot.node.pos[0],
|
||||
slot.boundingRect[1] + 10 - slot.node.pos[1]
|
||||
]
|
||||
expect(x).toBe(slotCentre[0])
|
||||
expect(y).toBeLessThan(slotCentre[1])
|
||||
})
|
||||
|
||||
it('draws output labels to the left of the slot', () => {
|
||||
const slot = createOutputSlot()
|
||||
|
||||
slot.draw(ctx, { colorContext })
|
||||
|
||||
const [, x] = vi.mocked(ctx.fillText).mock.calls[0]
|
||||
const slotCentreX = slot.boundingRect[0] + 10 - slot.node.pos[0]
|
||||
expect(x).toBeLessThan(slotCentreX)
|
||||
})
|
||||
|
||||
it('draws output labels above the slot when directed down', () => {
|
||||
const slot = createOutputSlot({ dir: LinkDirection.DOWN })
|
||||
|
||||
slot.draw(ctx, { colorContext })
|
||||
|
||||
const [, , y] = vi.mocked(ctx.fillText).mock.calls[0]
|
||||
const slotCentreY = slot.boundingRect[1] + 10 - slot.node.pos[1]
|
||||
expect(y).toBeLessThan(slotCentreY)
|
||||
})
|
||||
|
||||
it('strokes output slots in normal quality', () => {
|
||||
const slot = createOutputSlot()
|
||||
|
||||
slot.draw(ctx, { colorContext })
|
||||
|
||||
expect(ctx.stroke).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not stroke output slots in low quality', () => {
|
||||
const slot = createOutputSlot()
|
||||
|
||||
slot.draw(ctx, { colorContext, lowQuality: true })
|
||||
|
||||
expect(ctx.stroke).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rings the slot in red when it has errors', () => {
|
||||
const slot = createInputSlot({ hasErrors: true })
|
||||
|
||||
slot.draw(ctx, { colorContext })
|
||||
|
||||
expect(ctx.arc).toHaveBeenCalledWith(
|
||||
expect.any(Number),
|
||||
expect.any(Number),
|
||||
12,
|
||||
0,
|
||||
Math.PI * 2
|
||||
)
|
||||
expect(ctx.stroke).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('highlightColor', () => {
|
||||
const original = {
|
||||
highlight: LiteGraph.NODE_TEXT_HIGHLIGHT_COLOR,
|
||||
selectedTitle: LiteGraph.NODE_SELECTED_TITLE_COLOR
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
LiteGraph.NODE_TEXT_HIGHLIGHT_COLOR = original.highlight
|
||||
LiteGraph.NODE_SELECTED_TITLE_COLOR = original.selectedTitle
|
||||
})
|
||||
|
||||
it('prefers the dedicated text highlight colour', () => {
|
||||
expect(createInputSlot().highlightColor).toBe(
|
||||
LiteGraph.NODE_TEXT_HIGHLIGHT_COLOR
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to the selected title colour, then text colour', () => {
|
||||
LiteGraph.NODE_TEXT_HIGHLIGHT_COLOR = undefined
|
||||
expect(createInputSlot().highlightColor).toBe(
|
||||
LiteGraph.NODE_SELECTED_TITLE_COLOR
|
||||
)
|
||||
|
||||
LiteGraph.NODE_SELECTED_TITLE_COLOR = undefined
|
||||
expect(createInputSlot().highlightColor).toBe(LiteGraph.NODE_TEXT_COLOR)
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderingLabel', () => {
|
||||
it.for<[string, Partial<NodeInputSlot>, string]>([
|
||||
['label', { label: 'A Label', localized_name: 'Localized' }, 'A Label'],
|
||||
['localized_name', { localized_name: 'Localized' }, 'Localized'],
|
||||
['name', {}, 'in'],
|
||||
['empty string', { name: '' }, '']
|
||||
])('falls back through %s', ([, overrides, expected]) => {
|
||||
expect(createInputSlot(overrides).renderingLabel).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('drawCollapsed', () => {
|
||||
it('draws a box for event slots', () => {
|
||||
createInputSlot({ type: SlotType.Event }).drawCollapsed(ctx)
|
||||
|
||||
expect(ctx.rect).toHaveBeenCalledTimes(1)
|
||||
expect(ctx.fill).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('draws a box for box-shaped slots', () => {
|
||||
createInputSlot({ shape: RenderShape.BOX }).drawCollapsed(ctx)
|
||||
|
||||
expect(ctx.rect).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('draws an input-facing arrow for arrow-shaped input slots', () => {
|
||||
createInputSlot({ shape: RenderShape.ARROW }).drawCollapsed(ctx)
|
||||
|
||||
expect(ctx.moveTo).toHaveBeenCalledWith(8, expect.any(Number))
|
||||
expect(ctx.closePath).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('draws an output-facing arrow for arrow-shaped output slots', () => {
|
||||
const node = createNode()
|
||||
node._collapsed_width = 60
|
||||
createOutputSlot({ shape: RenderShape.ARROW }, node).drawCollapsed(ctx)
|
||||
|
||||
expect(ctx.moveTo).toHaveBeenCalledWith(66, expect.any(Number))
|
||||
})
|
||||
|
||||
it('draws a circle by default', () => {
|
||||
createInputSlot().drawCollapsed(ctx)
|
||||
|
||||
expect(ctx.arc).toHaveBeenCalledWith(
|
||||
expect.any(Number),
|
||||
expect.any(Number),
|
||||
4,
|
||||
0,
|
||||
Math.PI * 2
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('collapsedPos', () => {
|
||||
it('places output slots at the collapsed node width', () => {
|
||||
const node = createNode()
|
||||
node._collapsed_width = 42
|
||||
|
||||
expect(createOutputSlot({}, node).collapsedPos[0]).toBe(42)
|
||||
})
|
||||
|
||||
it('falls back to the default collapsed width', () => {
|
||||
const slot = createOutputSlot()
|
||||
|
||||
expect(slot.collapsedPos[0]).toBe(LiteGraph.NODE_COLLAPSED_WIDTH)
|
||||
})
|
||||
|
||||
it('places input slots at the node origin', () => {
|
||||
expect(createInputSlot().collapsedPos[0]).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isValidTarget', () => {
|
||||
it('validates input slots against output slots', () => {
|
||||
const input = createInputSlot()
|
||||
const output = createOutputSlot()
|
||||
|
||||
expect(input.isValidTarget(output)).toBe(true)
|
||||
expect(output.isValidTarget(input)).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects connections between incompatible slot types', () => {
|
||||
const input = createInputSlot()
|
||||
const output = createOutputSlot({ type: 'INT' })
|
||||
|
||||
expect(input.isValidTarget(output)).toBe(false)
|
||||
})
|
||||
|
||||
it('validates output slots against subgraph outputs', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
outputs: [{ name: 'value', type: 'STRING' }]
|
||||
})
|
||||
const subgraphOutput = subgraph.outputNode.slots[0]
|
||||
|
||||
expect(createOutputSlot().isValidTarget(subgraphOutput)).toBe(true)
|
||||
expect(createOutputSlot({ type: 'INT' }).isValidTarget(subgraphOutput)) //
|
||||
.toBe(false)
|
||||
})
|
||||
|
||||
it('validates input slots against subgraph inputs', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'STRING' }]
|
||||
})
|
||||
const subgraphInput = subgraph.inputNode.slots[0]
|
||||
|
||||
expect(createInputSlot().isValidTarget(subgraphInput)).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects unknown slot shapes', () => {
|
||||
const input = createInputSlot()
|
||||
const output = createOutputSlot()
|
||||
|
||||
expect(input.isValidTarget(fromPartial({ type: 'STRING' }))).toBe(false)
|
||||
expect(output.isValidTarget(fromPartial({ type: 'STRING' }))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isConnected', () => {
|
||||
it('reports output connectivity from the links array', () => {
|
||||
expect(createOutputSlot().isConnected).toBe(false)
|
||||
expect(createOutputSlot({ links: [] }).isConnected).toBe(false)
|
||||
expect(createOutputSlot({ links: [toLinkId(1)] }).isConnected).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,90 +1,18 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type {
|
||||
INodeInputSlot,
|
||||
INodeOutputSlot
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
NodeInputSlot,
|
||||
NodeOutputSlot,
|
||||
inputAsSerialisable,
|
||||
outputAsSerialisable
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { SlotType } from '@/lib/litegraph/src/draw'
|
||||
import type {
|
||||
DefaultConnectionColors,
|
||||
ReadOnlyRect
|
||||
} from '@/lib/litegraph/src/interfaces'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import {
|
||||
LinkDirection,
|
||||
RenderShape
|
||||
} from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { toLinkId } from '@/types/linkId'
|
||||
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
|
||||
|
||||
const boundingRect: ReadOnlyRect = [0, 0, 10, 10]
|
||||
|
||||
type MockCanvasContext = CanvasRenderingContext2D & {
|
||||
arc: ReturnType<typeof vi.fn>
|
||||
beginPath: ReturnType<typeof vi.fn>
|
||||
clip: ReturnType<typeof vi.fn>
|
||||
closePath: ReturnType<typeof vi.fn>
|
||||
fill: ReturnType<typeof vi.fn>
|
||||
fillText: ReturnType<typeof vi.fn>
|
||||
lineTo: ReturnType<typeof vi.fn>
|
||||
moveTo: ReturnType<typeof vi.fn>
|
||||
rect: ReturnType<typeof vi.fn>
|
||||
restore: ReturnType<typeof vi.fn>
|
||||
save: ReturnType<typeof vi.fn>
|
||||
stroke: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
function createContext(): MockCanvasContext {
|
||||
return fromPartial<MockCanvasContext>({
|
||||
fillStyle: '#initial-fill',
|
||||
strokeStyle: '#initial-stroke',
|
||||
lineWidth: 7,
|
||||
textAlign: 'start',
|
||||
arc: vi.fn(),
|
||||
beginPath: vi.fn(),
|
||||
clip: vi.fn(),
|
||||
closePath: vi.fn(),
|
||||
fill: vi.fn(),
|
||||
fillText: vi.fn(),
|
||||
lineTo: vi.fn(),
|
||||
moveTo: vi.fn(),
|
||||
rect: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
save: vi.fn(),
|
||||
stroke: vi.fn()
|
||||
})
|
||||
}
|
||||
|
||||
function createColors(): DefaultConnectionColors {
|
||||
return {
|
||||
getConnectedColor: vi.fn((type) => `connected-${type}`),
|
||||
getDisconnectedColor: vi.fn((type) => `disconnected-${type}`)
|
||||
}
|
||||
}
|
||||
|
||||
function createNode(): LGraphNode {
|
||||
return {
|
||||
pos: [100, 200],
|
||||
_collapsed_width: 80
|
||||
} as LGraphNode
|
||||
}
|
||||
|
||||
describe('NodeSlot', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal(
|
||||
'Path2D',
|
||||
class {
|
||||
arc = vi.fn()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('inputAsSerialisable', () => {
|
||||
it('removes _data from serialized slot', () => {
|
||||
const slot: INodeOutputSlot = {
|
||||
@@ -146,328 +74,4 @@ describe('NodeSlot', () => {
|
||||
expect(serialized.widget).not.toHaveProperty('options')
|
||||
})
|
||||
})
|
||||
|
||||
describe('rendering', () => {
|
||||
it('draws an input label on the right and restores canvas styles', () => {
|
||||
const ctx = createContext()
|
||||
const slot = new NodeInputSlot(
|
||||
{
|
||||
name: 'input',
|
||||
label: 'Input label',
|
||||
type: 'FLOAT',
|
||||
link: null,
|
||||
boundingRect: [110, 210, 10, 10]
|
||||
},
|
||||
createNode()
|
||||
)
|
||||
|
||||
slot.draw(ctx, { colorContext: createColors(), highlight: true })
|
||||
|
||||
expect(ctx.arc).toHaveBeenCalledWith(15, 15, 5, 0, Math.PI * 2)
|
||||
expect(ctx.fillText).toHaveBeenCalledWith('Input label', 25, 20)
|
||||
expect(ctx.fillStyle).toBe('#initial-fill')
|
||||
expect(ctx.strokeStyle).toBe('#initial-stroke')
|
||||
expect(ctx.lineWidth).toBe(7)
|
||||
expect(ctx.textAlign).toBe('start')
|
||||
})
|
||||
|
||||
it('draws output labels on the left and strokes output slots', () => {
|
||||
const ctx = createContext()
|
||||
const slot = new NodeOutputSlot(
|
||||
{
|
||||
name: 'output',
|
||||
localized_name: 'Localized output',
|
||||
type: 'FLOAT',
|
||||
links: [toLinkId(1)],
|
||||
boundingRect: [110, 210, 10, 10]
|
||||
},
|
||||
createNode()
|
||||
)
|
||||
|
||||
slot.draw(ctx, { colorContext: createColors() })
|
||||
|
||||
expect(ctx.stroke).toHaveBeenCalled()
|
||||
expect(ctx.fillText).toHaveBeenCalledWith('Localized output', 5, 20)
|
||||
expect(ctx.textAlign).toBe('start')
|
||||
expect(ctx.strokeStyle).toBe('#initial-stroke')
|
||||
})
|
||||
|
||||
it('draws event, box, arrow, grid, and low-quality slot shapes', () => {
|
||||
const colorContext = createColors()
|
||||
const node = createNode()
|
||||
const eventCtx = createContext()
|
||||
const boxCtx = createContext()
|
||||
const arrowCtx = createContext()
|
||||
const gridCtx = createContext()
|
||||
const lowQualityCtx = createContext()
|
||||
|
||||
new NodeInputSlot(
|
||||
{
|
||||
name: 'event',
|
||||
type: SlotType.Event,
|
||||
link: null,
|
||||
boundingRect: [110, 210, 10, 10]
|
||||
},
|
||||
node
|
||||
).draw(eventCtx, { colorContext })
|
||||
new NodeInputSlot(
|
||||
{
|
||||
name: 'box',
|
||||
type: 'FLOAT',
|
||||
shape: RenderShape.BOX,
|
||||
link: null,
|
||||
boundingRect: [110, 210, 10, 10]
|
||||
},
|
||||
node
|
||||
).draw(boxCtx, { colorContext })
|
||||
new NodeOutputSlot(
|
||||
{
|
||||
name: 'arrow',
|
||||
type: 'FLOAT',
|
||||
shape: RenderShape.ARROW,
|
||||
links: null,
|
||||
boundingRect: [110, 210, 10, 10]
|
||||
},
|
||||
node
|
||||
).draw(arrowCtx, { colorContext })
|
||||
new NodeInputSlot(
|
||||
{
|
||||
name: 'grid',
|
||||
type: SlotType.Array,
|
||||
link: null,
|
||||
boundingRect: [110, 210, 10, 10]
|
||||
},
|
||||
node
|
||||
).draw(gridCtx, { colorContext })
|
||||
new NodeInputSlot(
|
||||
{
|
||||
name: 'low',
|
||||
type: 'FLOAT',
|
||||
link: null,
|
||||
boundingRect: [110, 210, 10, 10]
|
||||
},
|
||||
node
|
||||
).draw(lowQualityCtx, { colorContext, lowQuality: true })
|
||||
|
||||
expect(eventCtx.rect).toHaveBeenCalledWith(9.5, 10.5, 14, 10)
|
||||
expect(boxCtx.rect).toHaveBeenCalledWith(9.5, 10.5, 14, 10)
|
||||
expect(arrowCtx.moveTo).toHaveBeenCalledWith(23, 15.5)
|
||||
expect(gridCtx.rect).toHaveBeenCalledTimes(9)
|
||||
expect(lowQualityCtx.rect).toHaveBeenCalledWith(11, 11, 8, 8)
|
||||
expect(lowQualityCtx.fillText).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('draws hollow and multi-type slots', () => {
|
||||
const colorContext = createColors()
|
||||
const hollowCtx = createContext()
|
||||
const multiCtx = createContext()
|
||||
|
||||
new NodeInputSlot(
|
||||
{
|
||||
name: 'hollow',
|
||||
type: 'FLOAT',
|
||||
shape: RenderShape.HollowCircle,
|
||||
link: null,
|
||||
boundingRect: [110, 210, 10, 10]
|
||||
},
|
||||
createNode()
|
||||
).draw(hollowCtx, { colorContext, highlight: true })
|
||||
new NodeInputSlot(
|
||||
{
|
||||
name: 'multi',
|
||||
type: 'A,B,C,D,E',
|
||||
link: toLinkId(1),
|
||||
boundingRect: [110, 210, 10, 10]
|
||||
},
|
||||
createNode()
|
||||
).draw(multiCtx, { colorContext })
|
||||
|
||||
expect(hollowCtx.clip).toHaveBeenCalledWith(expect.any(Object), 'evenodd')
|
||||
expect(
|
||||
vi
|
||||
.mocked(colorContext.getConnectedColor)
|
||||
.mock.calls.some(([type]) => type === 'A')
|
||||
).toBe(true)
|
||||
expect(multiCtx.fill.mock.calls.length).toBeGreaterThan(1)
|
||||
expect(multiCtx.stroke).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hides widget input labels and draws error rings', () => {
|
||||
const ctx = createContext()
|
||||
const slot = new NodeInputSlot(
|
||||
{
|
||||
name: 'widget-input',
|
||||
label: 'Hidden label',
|
||||
type: 'FLOAT',
|
||||
link: null,
|
||||
widget: { name: 'widget' },
|
||||
hasErrors: true,
|
||||
boundingRect: [110, 210, 10, 10]
|
||||
},
|
||||
createNode()
|
||||
)
|
||||
|
||||
slot.draw(ctx, { colorContext: createColors() })
|
||||
|
||||
expect(ctx.fillText).not.toHaveBeenCalled()
|
||||
expect(ctx.arc).toHaveBeenCalledWith(15, 15, 12, 0, Math.PI * 2)
|
||||
expect(ctx.stroke).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('places directional labels above vertical slots', () => {
|
||||
const rightCtx = createContext()
|
||||
const leftCtx = createContext()
|
||||
const node = createNode()
|
||||
const input = new NodeInputSlot(
|
||||
{
|
||||
name: 'up',
|
||||
type: 'FLOAT',
|
||||
link: null,
|
||||
dir: LinkDirection.UP,
|
||||
boundingRect: [110, 210, 10, 10]
|
||||
},
|
||||
node
|
||||
)
|
||||
const output = new NodeOutputSlot(
|
||||
{
|
||||
name: 'down',
|
||||
type: 'FLOAT',
|
||||
links: null,
|
||||
dir: LinkDirection.DOWN,
|
||||
boundingRect: [110, 210, 10, 10]
|
||||
},
|
||||
node
|
||||
)
|
||||
|
||||
input.draw(rightCtx, { colorContext: createColors() })
|
||||
output.draw(leftCtx, { colorContext: createColors() })
|
||||
|
||||
expect(rightCtx.fillText).toHaveBeenCalledWith('up', 15, 5)
|
||||
expect(leftCtx.fillText).toHaveBeenCalledWith('down', 15, 7)
|
||||
})
|
||||
})
|
||||
|
||||
describe('collapsed rendering', () => {
|
||||
it('draws collapsed input and output arrows in their own directions', () => {
|
||||
const inputCtx = createContext()
|
||||
const outputCtx = createContext()
|
||||
|
||||
new NodeInputSlot(
|
||||
{
|
||||
name: 'input',
|
||||
type: 'FLOAT',
|
||||
shape: RenderShape.ARROW,
|
||||
link: null,
|
||||
boundingRect
|
||||
},
|
||||
createNode()
|
||||
).drawCollapsed(inputCtx)
|
||||
new NodeOutputSlot(
|
||||
{
|
||||
name: 'output',
|
||||
type: 'FLOAT',
|
||||
shape: RenderShape.ARROW,
|
||||
links: null,
|
||||
boundingRect
|
||||
},
|
||||
createNode()
|
||||
).drawCollapsed(outputCtx)
|
||||
|
||||
expect(inputCtx.moveTo).toHaveBeenCalledWith(8, -15)
|
||||
expect(inputCtx.lineTo).toHaveBeenCalledWith(-4, -19)
|
||||
expect(outputCtx.moveTo).toHaveBeenCalledWith(86, -15)
|
||||
expect(outputCtx.lineTo).toHaveBeenCalledWith(74, -19)
|
||||
})
|
||||
|
||||
it('draws collapsed event and circle slots', () => {
|
||||
const eventCtx = createContext()
|
||||
const circleCtx = createContext()
|
||||
|
||||
new NodeInputSlot(
|
||||
{
|
||||
name: 'event',
|
||||
type: SlotType.Event,
|
||||
link: null,
|
||||
boundingRect
|
||||
},
|
||||
createNode()
|
||||
).drawCollapsed(eventCtx)
|
||||
new NodeInputSlot(
|
||||
{
|
||||
name: 'circle',
|
||||
type: 'FLOAT',
|
||||
link: null,
|
||||
boundingRect
|
||||
},
|
||||
createNode()
|
||||
).drawCollapsed(circleCtx)
|
||||
|
||||
expect(eventCtx.rect).toHaveBeenCalledWith(-6.5, -19, 14, 8)
|
||||
expect(circleCtx.arc).toHaveBeenCalledWith(0, -15, 4, 0, Math.PI * 2)
|
||||
expect(circleCtx.fillStyle).toBe('#initial-fill')
|
||||
})
|
||||
})
|
||||
|
||||
describe('serialization and validation', () => {
|
||||
it('serializes slot fields without the node reference', () => {
|
||||
const slot = new NodeOutputSlot(
|
||||
{
|
||||
name: 'out',
|
||||
type: 'FLOAT',
|
||||
label: 'Output',
|
||||
color_on: '#fff',
|
||||
color_off: '#000',
|
||||
shape: RenderShape.BOX,
|
||||
dir: LinkDirection.RIGHT,
|
||||
localized_name: 'Localized',
|
||||
pos: [1, 2],
|
||||
links: [toLinkId(3)],
|
||||
slot_index: 4,
|
||||
boundingRect: [1, 2, 3, 4]
|
||||
},
|
||||
createNode()
|
||||
)
|
||||
|
||||
expect(slot.toJSON()).toEqual({
|
||||
name: 'out',
|
||||
type: 'FLOAT',
|
||||
label: 'Output',
|
||||
color_on: '#fff',
|
||||
color_off: '#000',
|
||||
shape: RenderShape.BOX,
|
||||
dir: LinkDirection.RIGHT,
|
||||
localized_name: 'Localized',
|
||||
pos: [1, 2],
|
||||
boundingRect: [1, 2, 3, 4],
|
||||
links: [toLinkId(3)],
|
||||
slot_index: 4
|
||||
})
|
||||
})
|
||||
|
||||
it('validates input and output targets by slot direction', () => {
|
||||
const input = new NodeInputSlot(
|
||||
{
|
||||
name: 'input',
|
||||
type: 'FLOAT',
|
||||
link: null,
|
||||
boundingRect
|
||||
},
|
||||
createNode()
|
||||
)
|
||||
const output = new NodeOutputSlot(
|
||||
{
|
||||
name: 'output',
|
||||
type: 'FLOAT',
|
||||
links: null,
|
||||
boundingRect
|
||||
},
|
||||
createNode()
|
||||
)
|
||||
|
||||
expect(input.isValidTarget(output)).toBe(true)
|
||||
expect(output.isValidTarget(input)).toBe(true)
|
||||
expect(input.isValidTarget(input)).toBe(false)
|
||||
expect(output.isValidTarget(output)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,7 +3,6 @@ import { describe, expect, it } from 'vitest'
|
||||
import type { INodeOutputSlot } from '@/lib/litegraph/src/interfaces'
|
||||
import type { IWidget } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { INumericWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { toLinkId } from '@/types/linkId'
|
||||
|
||||
import { outputAsSerialisable } from './slotUtils'
|
||||
@@ -34,18 +33,4 @@ describe('outputAsSerialisable', () => {
|
||||
const serialised = outputAsSerialisable(output as OutputSlotParam)
|
||||
expect(serialised.links).toBeNull()
|
||||
})
|
||||
|
||||
it('serialises only the widget name for outputs with widgets', () => {
|
||||
const node = new LGraphNode('test')
|
||||
const output = node.addOutput('out', 'number') as OutputSlotParam
|
||||
output.widget = node.addWidget(
|
||||
'number',
|
||||
'my-widget',
|
||||
0,
|
||||
null
|
||||
) as INumericWidget
|
||||
|
||||
const serialised = outputAsSerialisable(output)
|
||||
expect(serialised.widget).toEqual({ name: 'my-widget' })
|
||||
})
|
||||
})
|
||||
|
||||