mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-16 08:49:09 +00:00
Compare commits
3 Commits
test/cover
...
feat/websi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b212563a9b | ||
|
|
824910787a | ||
|
|
e3431d25ec |
@@ -46,9 +46,3 @@ ALGOLIA_API_KEY=684d998c36b67a9a9fce8fc2d8860579
|
||||
# SENTRY_ORG=comfy-org
|
||||
# SENTRY_PROJECT=cloud-frontend-staging
|
||||
# SENTRY_PROJECT_PROD= # prod project slug for sourcemap uploads
|
||||
|
||||
# Ashby (apps/website careers page build).
|
||||
# Server-only; read inside the Astro build context. Do NOT prefix with PUBLIC_.
|
||||
# When unset, the committed snapshot at apps/website/src/data/ashby-roles.snapshot.json is used.
|
||||
# WEBSITE_ASHBY_API_KEY=
|
||||
# WEBSITE_ASHBY_JOB_BOARD_NAME=comfy-org
|
||||
|
||||
1
apps/website/.gitignore
vendored
1
apps/website/.gitignore
vendored
@@ -2,7 +2,6 @@ dist/
|
||||
.astro/
|
||||
test-results/
|
||||
playwright-report/
|
||||
results.json
|
||||
|
||||
# Platform-specific Playwright snapshots (CI runs Linux)
|
||||
*-win32.png
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
# @comfyorg/website
|
||||
|
||||
Marketing/brand website built with Astro + Vue.
|
||||
|
||||
## Ashby careers integration
|
||||
|
||||
`/careers` and `/zh-CN/careers` are rendered from Ashby's public job board
|
||||
API at build time. Data flow:
|
||||
|
||||
1. `src/pages/careers.astro` awaits `fetchRolesForBuild()` during the
|
||||
Astro build.
|
||||
2. `src/utils/ashby.ts` calls
|
||||
`GET https://api.ashbyhq.com/posting-api/job-board/{board}?includeCompensation=false`,
|
||||
validates the envelope and each posting with Zod
|
||||
(`src/utils/ashby.schema.ts`), and maps to the domain type in
|
||||
`src/data/roles.ts`.
|
||||
3. On any failure (network, HTTP 4xx/5xx, envelope schema drift),
|
||||
the fetcher falls back to the committed JSON snapshot at
|
||||
`src/data/ashby-roles.snapshot.json`.
|
||||
4. `src/utils/ashby.ci.ts` emits GitHub Actions annotations and a
|
||||
`$GITHUB_STEP_SUMMARY` block so stale fetches are visible on green
|
||||
builds.
|
||||
|
||||
### Required environment variables
|
||||
|
||||
Both are build-time only. Never prefix with `PUBLIC_` (Astro would
|
||||
inline that into the client bundle).
|
||||
|
||||
| Name | Purpose | Default (when unset) |
|
||||
| ------------------------------ | --------------------------- | --------------------------------- |
|
||||
| `WEBSITE_ASHBY_API_KEY` | Ashby API key (Basic auth) | Build uses the committed snapshot |
|
||||
| `WEBSITE_ASHBY_JOB_BOARD_NAME` | Ashby public job board slug | Build uses the committed snapshot |
|
||||
|
||||
### CI wiring (manual step — required)
|
||||
|
||||
This repo's `.github/workflows/*.yaml` changes cannot be pushed by a
|
||||
GitHub App. A maintainer must apply the following edits **once**:
|
||||
|
||||
**`.github/workflows/ci-website-build.yaml`** — pass the env into the
|
||||
build step and run the unit tests before it:
|
||||
|
||||
```yaml
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
|
||||
- name: Run website unit tests
|
||||
run: pnpm --filter @comfyorg/website test:unit
|
||||
|
||||
- name: Build website
|
||||
env:
|
||||
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
|
||||
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ vars.WEBSITE_ASHBY_JOB_BOARD_NAME || 'comfy-org' }}
|
||||
run: pnpm --filter @comfyorg/website build
|
||||
|
||||
- name: Verify API key is not leaked into build output
|
||||
env:
|
||||
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
|
||||
run: |
|
||||
set +x
|
||||
if [ -z "${WEBSITE_ASHBY_API_KEY:-}" ]; then
|
||||
echo "Secret not available in this run; skipping leak check."
|
||||
exit 0
|
||||
fi
|
||||
# grep -rlF prints only file paths (never match content).
|
||||
MATCHES=$(grep -rlF --exclude-dir=node_modules --null \
|
||||
-e "$WEBSITE_ASHBY_API_KEY" apps/website/dist/ 2>/dev/null \
|
||||
| tr '\0' '\n' || true)
|
||||
if [ -n "$MATCHES" ]; then
|
||||
echo "::error title=Ashby API key leaked into build output::$MATCHES"
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
**`.github/workflows/ci-vercel-website-preview.yaml`** — add the
|
||||
two env vars to the top-level `env:` block so `vercel build` (both
|
||||
`deploy-preview` and `deploy-production` jobs) sees them:
|
||||
|
||||
```yaml
|
||||
env:
|
||||
VERCEL_ORG_ID: ${{ secrets.VERCEL_WEBSITE_ORG_ID }}
|
||||
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_WEBSITE_PROJECT_ID }}
|
||||
VERCEL_TOKEN: ${{ secrets.VERCEL_WEBSITE_TOKEN }}
|
||||
VERCEL_SCOPE: comfyui
|
||||
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
|
||||
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ vars.WEBSITE_ASHBY_JOB_BOARD_NAME || 'comfy-org' }}
|
||||
```
|
||||
|
||||
The secret must also be added to the Vercel project environment
|
||||
(`vercel env add WEBSITE_ASHBY_API_KEY …` or via the Vercel UI) so
|
||||
that `vercel build` in the preview job has access to it.
|
||||
|
||||
Fork PRs do not exercise this path: `ci-vercel-website-preview.yaml`
|
||||
receives an empty `VERCEL_TOKEN` for forks and fails at `vercel pull`
|
||||
before the build runs. Fork-safe PR interactions (the preview-URL
|
||||
comment) are handled by `pr-vercel-website-preview.yaml`.
|
||||
|
||||
### Refreshing the snapshot
|
||||
|
||||
When a maintainer wants to update the committed snapshot (e.g. after
|
||||
onboarding/offboarding roles):
|
||||
|
||||
```bash
|
||||
WEBSITE_ASHBY_API_KEY=… WEBSITE_ASHBY_JOB_BOARD_NAME=comfy-org \
|
||||
pnpm --filter @comfyorg/website ashby:refresh-snapshot
|
||||
git commit apps/website/src/data/ashby-roles.snapshot.json
|
||||
```
|
||||
|
||||
The script exits non-zero on any non-fresh outcome so stale/empty
|
||||
snapshots can't be accidentally committed.
|
||||
|
||||
## Scripts
|
||||
|
||||
- `pnpm dev` — Astro dev server
|
||||
- `pnpm build` — production build to `dist/`
|
||||
- `pnpm typecheck` — `astro check`
|
||||
- `pnpm test:unit` — Vitest unit tests
|
||||
- `pnpm test:e2e` — Playwright E2E tests (requires `pnpm build` first)
|
||||
- `pnpm ashby:refresh-snapshot` — refresh the committed careers snapshot
|
||||
@@ -7,12 +7,6 @@ export default defineConfig({
|
||||
site: 'https://comfy.org',
|
||||
output: 'static',
|
||||
prefetch: { prefetchAll: true },
|
||||
redirects: {
|
||||
'/cloud/enterprise-case-studies/comfyui-at-architectural-scale-how-moment-factory-reimagined-3d-projection-mapping':
|
||||
'/customers/moment-factory/',
|
||||
'/cloud/enterprise-case-studies/how-series-entertainment-rebuilt-game-and-video-production-with-comfyui':
|
||||
'/customers/series-entertainment/'
|
||||
},
|
||||
build: {
|
||||
assets: '_website'
|
||||
},
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { test } from './fixtures/blockExternalMedia'
|
||||
|
||||
test.describe('Careers page @smoke', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/careers')
|
||||
})
|
||||
|
||||
test('has correct title', async ({ page }) => {
|
||||
await expect(page).toHaveTitle('Careers — Comfy')
|
||||
})
|
||||
|
||||
test('Roles section heading is visible', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Roles', level: 2 })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('renders at least one role from the snapshot', async ({ page }) => {
|
||||
const roles = page.getByTestId('careers-role-link')
|
||||
await expect(roles.first()).toBeVisible()
|
||||
expect(await roles.count()).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('each role links to jobs.ashbyhq.com', async ({ page }) => {
|
||||
const roles = page.getByTestId('careers-role-link')
|
||||
const count = await roles.count()
|
||||
for (let i = 0; i < count; i++) {
|
||||
const href = await roles.nth(i).getAttribute('href')
|
||||
expect(href).toMatch(/^https:\/\/jobs\.ashbyhq\.com\//)
|
||||
}
|
||||
})
|
||||
|
||||
test('ENGINEERING category filter narrows the role list', async ({
|
||||
page
|
||||
}) => {
|
||||
const allCount = await page.getByTestId('careers-role-link').count()
|
||||
await page.getByRole('button', { name: 'ENGINEERING', exact: true }).click()
|
||||
const engineeringLocator = page.getByTestId('careers-role-link')
|
||||
await expect(engineeringLocator.first()).toBeVisible()
|
||||
const engineeringCount = await engineeringLocator.count()
|
||||
expect(engineeringCount).toBeLessThanOrEqual(allCount)
|
||||
expect(engineeringCount).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Careers page (zh-CN) @smoke', () => {
|
||||
test('renders localized heading and roles', async ({ page }) => {
|
||||
await page.goto('/zh-CN/careers')
|
||||
await expect(page).toHaveTitle('招聘 — Comfy')
|
||||
await expect(
|
||||
page.getByRole('heading', { name: '职位', level: 2 })
|
||||
).toBeVisible()
|
||||
await expect(page.getByTestId('careers-role-link').first()).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -9,13 +9,10 @@
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"typecheck": "astro check",
|
||||
"test:unit": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:local": "cross-env PLAYWRIGHT_LOCAL=1 playwright test",
|
||||
"test:visual": "playwright test --project visual",
|
||||
"test:visual:update": "playwright test --project visual --update-snapshots",
|
||||
"ashby:refresh-snapshot": "tsx ./scripts/refresh-ashby-snapshot.ts"
|
||||
"test:visual:update": "playwright test --project visual --update-snapshots"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/sitemap": "catalog:",
|
||||
@@ -26,8 +23,7 @@
|
||||
"cva": "catalog:",
|
||||
"gsap": "catalog:",
|
||||
"lenis": "catalog:",
|
||||
"vue": "catalog:",
|
||||
"zod": "catalog:"
|
||||
"vue": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/check": "catalog:",
|
||||
@@ -36,9 +32,7 @@
|
||||
"@tailwindcss/vite": "catalog:",
|
||||
"astro": "catalog:",
|
||||
"tailwindcss": "catalog:",
|
||||
"tsx": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"nx": {
|
||||
"tags": [
|
||||
@@ -95,22 +89,6 @@
|
||||
"command": "astro check"
|
||||
}
|
||||
},
|
||||
"test:unit": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": true,
|
||||
"options": {
|
||||
"cwd": "apps/website",
|
||||
"command": "vitest run"
|
||||
}
|
||||
},
|
||||
"test:coverage": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": true,
|
||||
"options": {
|
||||
"cwd": "apps/website",
|
||||
"command": "vitest run --coverage"
|
||||
}
|
||||
},
|
||||
"test:e2e": {
|
||||
"executor": "nx:run-commands",
|
||||
"dependsOn": [
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { renameSync, writeFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { fetchRolesForBuild } from '../src/utils/ashby'
|
||||
|
||||
const snapshotPath = fileURLToPath(
|
||||
new URL('../src/data/ashby-roles.snapshot.json', import.meta.url)
|
||||
)
|
||||
const tempPath = `${snapshotPath}.tmp`
|
||||
|
||||
const outcome = await fetchRolesForBuild()
|
||||
|
||||
if (outcome.status !== 'fresh') {
|
||||
const reason = 'reason' in outcome ? outcome.reason : '(none)'
|
||||
console.error(
|
||||
`Snapshot refresh aborted. Outcome: ${outcome.status}; reason: ${reason}`
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
writeFileSync(
|
||||
tempPath,
|
||||
JSON.stringify(outcome.snapshot, null, 2) + '\n',
|
||||
'utf8'
|
||||
)
|
||||
renameSync(tempPath, snapshotPath)
|
||||
const totalRoles = outcome.snapshot.departments.reduce(
|
||||
(n, d) => n + d.roles.length,
|
||||
0
|
||||
)
|
||||
process.stdout.write(
|
||||
`Wrote snapshot with ${totalRoles} role(s) to ${snapshotPath}\n`
|
||||
)
|
||||
@@ -1,42 +1,121 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { Department } from '../../data/roles'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
import CategoryNav from '../common/CategoryNav.vue'
|
||||
import SectionLabel from '../common/SectionLabel.vue'
|
||||
|
||||
const { locale = 'en', departments = [] } = defineProps<{
|
||||
locale?: Locale
|
||||
departments?: readonly Department[]
|
||||
}>()
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const activeCategory = ref('all')
|
||||
|
||||
const visibleDepartments = computed(() =>
|
||||
departments.filter((d) => d.roles.length > 0)
|
||||
)
|
||||
interface Role {
|
||||
title: string
|
||||
department: string
|
||||
location: string
|
||||
id: string
|
||||
}
|
||||
|
||||
interface Department {
|
||||
name: string
|
||||
key: string
|
||||
roles: Role[]
|
||||
}
|
||||
|
||||
const departments: Department[] = [
|
||||
{
|
||||
name: 'ENGINEERING',
|
||||
key: 'engineering',
|
||||
roles: [
|
||||
{
|
||||
title: 'Design Engineer',
|
||||
department: 'Engineering',
|
||||
location: 'San Francisco',
|
||||
id: 'abc787b9-ad85-421c-8218-debd23bea096'
|
||||
},
|
||||
{
|
||||
title: 'Software Engineer',
|
||||
department: 'Engineering',
|
||||
location: 'San Francisco',
|
||||
id: '99dc26c7-51ca-43cd-a1ba-7d475a0f4a40'
|
||||
},
|
||||
{
|
||||
title: 'Product Manager',
|
||||
department: 'Engineering',
|
||||
location: 'London, UK',
|
||||
id: '12dbc26e-9f6d-49bf-83c6-130f7566d03c'
|
||||
},
|
||||
{
|
||||
title: 'Tech Lead Manager, Frontend',
|
||||
department: 'Engineering',
|
||||
location: 'San Francisco',
|
||||
id: 'a0665088-3314-457a-aa7b-12ca5c3eb261'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'DESIGN',
|
||||
key: 'design',
|
||||
roles: [
|
||||
{
|
||||
title: 'Creative Director',
|
||||
department: 'Design',
|
||||
location: 'San Francisco',
|
||||
id: '49fa0b07-3fa1-4a3a-b2c6-d2cc684ad63f'
|
||||
},
|
||||
{
|
||||
title: 'Graphic Designer',
|
||||
department: 'Design',
|
||||
location: 'London, UK',
|
||||
id: '19ba10aa-4961-45e8-8473-66a8a7a8079d'
|
||||
},
|
||||
{
|
||||
title: 'Freelance Motion Designer',
|
||||
department: 'Design',
|
||||
location: 'Remote',
|
||||
id: 'a7ccc2b4-4d9d-4e04-b39c-28a711995b5b'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'MARKETING',
|
||||
key: 'marketing',
|
||||
roles: [
|
||||
{
|
||||
title: 'Lifecycle Growth Marketer',
|
||||
department: 'Marketing',
|
||||
location: 'San Francisco',
|
||||
id: 'be74d210-3b50-408c-9f61-8fee8833ce64'
|
||||
},
|
||||
{
|
||||
title: 'Graphic Designer',
|
||||
department: 'Marketing',
|
||||
location: 'London, UK',
|
||||
id: '28dea965-662b-4786-b024-c9a1b6bc1f23'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const categories = computed(() => [
|
||||
{ label: 'ALL', value: 'all' },
|
||||
...visibleDepartments.value.map((d) => ({ label: d.name, value: d.key }))
|
||||
...departments.map((d) => ({ label: d.name, value: d.key }))
|
||||
])
|
||||
|
||||
const filteredDepartments = computed(() =>
|
||||
activeCategory.value === 'all'
|
||||
? visibleDepartments.value
|
||||
: visibleDepartments.value.filter((d) => d.key === activeCategory.value)
|
||||
? departments
|
||||
: departments.filter((d) => d.key === activeCategory.value)
|
||||
)
|
||||
|
||||
const hasRoles = computed(() => visibleDepartments.value.length > 0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="px-6 py-20 md:px-20 md:py-32" data-testid="careers-roles">
|
||||
<section class="px-6 py-20 md:px-20 md:py-32">
|
||||
<div class="mx-auto max-w-6xl">
|
||||
<div class="flex flex-col gap-12 md:flex-row md:gap-20">
|
||||
<!-- Left sidebar -->
|
||||
<div class="shrink-0 md:w-48">
|
||||
<div
|
||||
class="bg-primary-comfy-ink sticky top-20 z-10 py-4 md:top-28 md:py-0"
|
||||
@@ -47,7 +126,6 @@ const hasRoles = computed(() => visibleDepartments.value.length > 0)
|
||||
{{ t('careers.roles.heading', locale) }}
|
||||
</h2>
|
||||
<CategoryNav
|
||||
v-if="hasRoles"
|
||||
v-model="activeCategory"
|
||||
:categories="categories"
|
||||
class="mt-4"
|
||||
@@ -55,15 +133,8 @@ const hasRoles = computed(() => visibleDepartments.value.length > 0)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Role listings -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<p
|
||||
v-if="!hasRoles"
|
||||
class="text-primary-warm-gray text-base md:text-lg"
|
||||
data-testid="careers-roles-empty"
|
||||
>
|
||||
{{ t('careers.roles.empty', locale) }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-for="dept in filteredDepartments"
|
||||
:key="dept.key"
|
||||
@@ -76,11 +147,10 @@ const hasRoles = computed(() => visibleDepartments.value.length > 0)
|
||||
<a
|
||||
v-for="role in dept.roles"
|
||||
:key="role.id"
|
||||
:href="role.applyUrl"
|
||||
:href="`https://jobs.ashbyhq.com/comfy-org/${role.id}`"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="border-primary-warm-gray/20 group flex items-center justify-between border-b py-5"
|
||||
data-testid="careers-role-link"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<span
|
||||
|
||||
@@ -13,13 +13,13 @@ const steps = [
|
||||
number: '01',
|
||||
titleKey: 'api.steps.step1.title' as const,
|
||||
descriptionKey: 'api.steps.step1.description' as const,
|
||||
image: 'https://media.comfy.org/website/enterprise/enterprise_node_1.webp'
|
||||
image: 'https://media.comfy.org/website/api/logo-purple.webp'
|
||||
},
|
||||
{
|
||||
number: '02',
|
||||
titleKey: 'api.steps.step2.title' as const,
|
||||
descriptionKey: 'api.steps.step2.description' as const,
|
||||
image: 'https://media.comfy.org/website/enterprise/enterprise_node_2.webp'
|
||||
image: 'https://media.comfy.org/website/api/logo-yellow.webp'
|
||||
},
|
||||
{
|
||||
number: '03',
|
||||
|
||||
@@ -10,12 +10,12 @@ const cards = [
|
||||
{
|
||||
titleKey: 'enterprise.byoKey.card1.title' as const,
|
||||
descriptionKey: 'enterprise.byoKey.card1.description' as const,
|
||||
image: 'https://media.comfy.org/website/enterprise/enterprise_node_1.webp'
|
||||
image: 'https://media.comfy.org/website/api/logo-purple.webp'
|
||||
},
|
||||
{
|
||||
titleKey: 'enterprise.byoKey.card2.title' as const,
|
||||
descriptionKey: 'enterprise.byoKey.card2.description' as const,
|
||||
image: 'https://media.comfy.org/website/enterprise/enterprise_node_2.webp'
|
||||
image: 'https://media.comfy.org/website/api/logo-yellow.webp'
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
@@ -16,11 +16,9 @@ const midRightRef = ref<HTMLElement>()
|
||||
const bottomLeftRef = ref<HTMLElement>()
|
||||
const bottomRightRef = ref<HTMLElement>()
|
||||
|
||||
const parallaxOpts = { trigger: sectionRef, mediaQuery: '(min-width: 1024px)' }
|
||||
|
||||
useParallax([topLeftRef, topRightRef], { ...parallaxOpts, y: 200 })
|
||||
useParallax([midLeftRef, midRightRef], { ...parallaxOpts, y: 300 })
|
||||
useParallax([bottomLeftRef, bottomRightRef], { ...parallaxOpts, y: 400 })
|
||||
useParallax([topLeftRef, topRightRef], { trigger: sectionRef, y: 200 })
|
||||
useParallax([midLeftRef, midRightRef], { trigger: sectionRef, y: 300 })
|
||||
useParallax([bottomLeftRef, bottomRightRef], { trigger: sectionRef, y: 400 })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -19,8 +19,6 @@ interface ParallaxOptions {
|
||||
start?: string
|
||||
/** ScrollTrigger end value (default: 'bottom top') */
|
||||
end?: string
|
||||
/** Media query string — animation only runs when matched (responsive) */
|
||||
mediaQuery?: string
|
||||
}
|
||||
|
||||
export function useParallax(
|
||||
@@ -28,27 +26,24 @@ export function useParallax(
|
||||
options: ParallaxOptions = {}
|
||||
) {
|
||||
const { fromY = 0, y = 200 } = options
|
||||
let ctx: gsap.Context | gsap.MatchMedia | undefined
|
||||
let ctx: gsap.Context | undefined
|
||||
|
||||
onMounted(() => {
|
||||
if (prefersReducedMotion()) return
|
||||
|
||||
const triggerEl = options.trigger?.value
|
||||
const els = elements
|
||||
.map((r) => r.value)
|
||||
.filter((el): el is HTMLElement => !!el && el.offsetParent !== null)
|
||||
if (!els.length || prefersReducedMotion()) return
|
||||
|
||||
const createAnimations = () => {
|
||||
const els = elements
|
||||
.map((r) => r.value)
|
||||
.filter((el): el is HTMLElement => !!el && el.offsetParent !== null)
|
||||
if (!els.length) return
|
||||
|
||||
const trigger = triggerEl ?? els[0]
|
||||
const scrollTrigger = {
|
||||
trigger,
|
||||
start: options.start ?? 'top bottom',
|
||||
end: options.end ?? 'bottom top',
|
||||
scrub: 1
|
||||
}
|
||||
const trigger = triggerEl ?? els[0]
|
||||
const scrollTrigger = {
|
||||
trigger,
|
||||
start: options.start ?? 'top bottom',
|
||||
end: options.end ?? 'bottom top',
|
||||
scrub: 1
|
||||
}
|
||||
|
||||
ctx = gsap.context(() => {
|
||||
els.forEach((el) => {
|
||||
gsap.fromTo(
|
||||
el,
|
||||
@@ -56,15 +51,7 @@ export function useParallax(
|
||||
{ y: resolve(y, el, trigger), ease: 'none', scrollTrigger }
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
if (options.mediaQuery) {
|
||||
const mm = gsap.matchMedia()
|
||||
mm.add(options.mediaQuery, createAnimations)
|
||||
ctx = mm
|
||||
} else {
|
||||
ctx = gsap.context(createAnimations)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
{
|
||||
"fetchedAt": "2026-04-24T18:59:03.989Z",
|
||||
"departments": [
|
||||
{
|
||||
"name": "DESIGN",
|
||||
"key": "design",
|
||||
"roles": [
|
||||
{
|
||||
"id": "4c5d6afb78652df7",
|
||||
"title": "Freelance Motion Designer",
|
||||
"department": "Design",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/a7ccc2b4-4d9d-4e04-b39c-28a711995b5b/application"
|
||||
},
|
||||
{
|
||||
"id": "0f5256cf302e552b",
|
||||
"title": "Creative Artist",
|
||||
"department": "Design",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/19ba10aa-4961-45e8-8473-66a8a7a8079d/application"
|
||||
},
|
||||
{
|
||||
"id": "e915f2c78b17f93b",
|
||||
"title": "Senior Product Designer",
|
||||
"department": "Design",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/b2e864c6-4754-4e04-8f46-1022baa103c3/application"
|
||||
},
|
||||
{
|
||||
"id": "b9f9a23219be7cd4",
|
||||
"title": "Design Engineer",
|
||||
"department": "Design",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/abc787b9-ad85-421c-8218-debd23bea096/application"
|
||||
},
|
||||
{
|
||||
"id": "5746486d87874937",
|
||||
"title": "Graphic Designer",
|
||||
"department": "Design",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/49fa0b07-3fa1-4a3a-b2c6-d2cc684ad63f/application"
|
||||
},
|
||||
{
|
||||
"id": "547b6ba622c800a5",
|
||||
"title": "Senior Product Designer - Craft",
|
||||
"department": "Design",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/a32c6769-b791-41f4-9225-50bbd8a1cf0f/application"
|
||||
},
|
||||
{
|
||||
"id": "7bb02634a24763bc",
|
||||
"title": "Staff Product Designer - Systems",
|
||||
"department": "Design",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/0bc8356b-615e-4f40-b632-fd3b2691be34/application"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "ENGINEERING",
|
||||
"key": "engineering",
|
||||
"roles": [
|
||||
{
|
||||
"id": "102d58e35a8a9817",
|
||||
"title": "Senior Software Engineer, Frontend",
|
||||
"department": "Engineering",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/c3e0584d-5490-491f-aae4-b5922ef63fd2/application"
|
||||
},
|
||||
{
|
||||
"id": "d01d69fba7743905",
|
||||
"title": "Senior Software Engineer, Backend Generalist",
|
||||
"department": "Engineering",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/732f8b39-076d-4847-afe3-f54d4451607e/application"
|
||||
},
|
||||
{
|
||||
"id": "f36f60cfd5bb5910",
|
||||
"title": "Senior/Staff Applied Machine Learning Engineer",
|
||||
"department": "Engineering",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/5cc4d0bc-97b0-463b-8466-3ec1d07f6ac0/application"
|
||||
},
|
||||
{
|
||||
"id": "9d8ec4c65e20b19e",
|
||||
"title": "Software Engineer, Frontend",
|
||||
"department": "Engineering",
|
||||
"location": "Remote",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/99dc26c7-51ca-43cd-a1ba-7d475a0f4a40/application"
|
||||
},
|
||||
{
|
||||
"id": "be94b193d1f4d482",
|
||||
"title": "Tech Lead Manager, Frontend",
|
||||
"department": "Engineering",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/a0665088-3314-457a-aa7b-12ca5c3eb261/application"
|
||||
},
|
||||
{
|
||||
"id": "ab48f5db6bd1783c",
|
||||
"title": "Software Engineer, Core ComfyUI Contributor",
|
||||
"department": "Engineering",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/7d4062d6-d500-445a-9a5f-014971af259f/application"
|
||||
},
|
||||
{
|
||||
"id": "c5dff4ee628bdcd1",
|
||||
"title": "Software Engineer, ComfyUI Desktop",
|
||||
"department": "Engineering",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/ad2f76cb-a787-47d8-81c5-7e7f917747c0/application"
|
||||
},
|
||||
{
|
||||
"id": "4302a7aaa87e16e3",
|
||||
"title": "Product Manager, ComfyUI",
|
||||
"department": "Engineering",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/9e4b9029-c3e9-436b-82c4-a1a9f1b8c16e/application"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "MARKETING",
|
||||
"key": "marketing",
|
||||
"roles": [
|
||||
{
|
||||
"id": "b5803a0d4785d406",
|
||||
"title": "Lifecycle Growth Marketer",
|
||||
"department": "Marketing",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/be74d210-3b50-408c-9f61-8fee8833ce64/application"
|
||||
},
|
||||
{
|
||||
"id": "130d7218d7895bdb",
|
||||
"title": "Partnership & Events Marketing Manager",
|
||||
"department": "Marketing",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/89d3ff75-2055-4e92-9c69-81feff55627c/application"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "OPERATIONS",
|
||||
"key": "operations",
|
||||
"roles": [
|
||||
{
|
||||
"id": "ec68ae44dd5943c9",
|
||||
"title": "Senior Technical Recruiter",
|
||||
"department": "Operations",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/d5008532-c45d-46e6-ba2c-20489d364362/application"
|
||||
},
|
||||
{
|
||||
"id": "16f556001ce1cef4",
|
||||
"title": "BizOps Strategist",
|
||||
"department": "Operations",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/145b8558-0ab4-43e8-8fac-b59059cf2537/application"
|
||||
},
|
||||
{
|
||||
"id": "8e773a72c1b8e099",
|
||||
"title": "Founding Customer Success Manager",
|
||||
"department": "Operations",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/a1c5c5ed-62ac-4767-af57-a3ba4e0bf5e4/application"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
export interface Role {
|
||||
id: string
|
||||
title: string
|
||||
department: string
|
||||
location: string
|
||||
applyUrl: string
|
||||
}
|
||||
|
||||
export interface Department {
|
||||
name: string
|
||||
key: string
|
||||
roles: Role[]
|
||||
}
|
||||
|
||||
export interface RolesSnapshot {
|
||||
fetchedAt: string
|
||||
departments: Department[]
|
||||
}
|
||||
@@ -1505,10 +1505,6 @@ const translations = {
|
||||
|
||||
// CareersRolesSection
|
||||
'careers.roles.heading': { en: 'Roles', 'zh-CN': '职位' },
|
||||
'careers.roles.empty': {
|
||||
en: 'No open roles right now. Check back soon.',
|
||||
'zh-CN': '目前暂无开放职位,请稍后再来查看。'
|
||||
},
|
||||
|
||||
// CareersFAQSection
|
||||
'careers.faq.heading': { en: 'Q&A', 'zh-CN': 'Q&A' },
|
||||
|
||||
@@ -5,20 +5,6 @@ import RolesSection from '../components/careers/RolesSection.vue'
|
||||
import WhyJoinSection from '../components/careers/WhyJoinSection.vue'
|
||||
import TeamPhotosSection from '../components/careers/TeamPhotosSection.vue'
|
||||
import FAQSection from '../components/common/FAQSection.vue'
|
||||
import { fetchRolesForBuild } from '../utils/ashby'
|
||||
import { reportAshbyOutcome } from '../utils/ashby.ci'
|
||||
|
||||
const outcome = await fetchRolesForBuild()
|
||||
reportAshbyOutcome(outcome)
|
||||
|
||||
if (outcome.status === 'failed') {
|
||||
throw new Error(
|
||||
`Ashby fetch failed and no snapshot is available. Reason: ${outcome.reason}. ` +
|
||||
'Run `pnpm --filter @comfyorg/website ashby:refresh-snapshot` locally and commit the snapshot.'
|
||||
)
|
||||
}
|
||||
|
||||
const departments = outcome.snapshot.departments
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
@@ -26,7 +12,7 @@ const departments = outcome.snapshot.departments
|
||||
description="Join the team building the operating system for generative AI. Open roles in engineering, design, marketing, and more."
|
||||
>
|
||||
<HeroSection />
|
||||
<RolesSection departments={departments} client:visible />
|
||||
<RolesSection client:visible />
|
||||
<WhyJoinSection client:visible />
|
||||
<TeamPhotosSection client:visible />
|
||||
<FAQSection
|
||||
|
||||
@@ -5,20 +5,6 @@ import RolesSection from '../../components/careers/RolesSection.vue'
|
||||
import WhyJoinSection from '../../components/careers/WhyJoinSection.vue'
|
||||
import TeamPhotosSection from '../../components/careers/TeamPhotosSection.vue'
|
||||
import FAQSection from '../../components/common/FAQSection.vue'
|
||||
import { fetchRolesForBuild } from '../../utils/ashby'
|
||||
import { reportAshbyOutcome } from '../../utils/ashby.ci'
|
||||
|
||||
const outcome = await fetchRolesForBuild()
|
||||
reportAshbyOutcome(outcome)
|
||||
|
||||
if (outcome.status === 'failed') {
|
||||
throw new Error(
|
||||
`Ashby fetch failed and no snapshot is available. Reason: ${outcome.reason}. ` +
|
||||
'Run `pnpm --filter @comfyorg/website ashby:refresh-snapshot` locally and commit the snapshot.'
|
||||
)
|
||||
}
|
||||
|
||||
const departments = outcome.snapshot.departments
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
@@ -26,7 +12,7 @@ const departments = outcome.snapshot.departments
|
||||
description="加入构建生成式 AI 操作系统的团队。工程、设计、市场营销等岗位开放招聘中。"
|
||||
>
|
||||
<HeroSection locale="zh-CN" />
|
||||
<RolesSection locale="zh-CN" departments={departments} client:visible />
|
||||
<RolesSection locale="zh-CN" client:visible />
|
||||
<WhyJoinSection locale="zh-CN" client:visible />
|
||||
<TeamPhotosSection client:visible />
|
||||
<FAQSection
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { FetchOutcome } from './ashby'
|
||||
import type { RolesSnapshot } from '../data/roles'
|
||||
|
||||
import { reportAshbyOutcome, resetAshbyReporterForTests } from './ashby.ci'
|
||||
|
||||
function baseSnapshot(): RolesSnapshot {
|
||||
return {
|
||||
fetchedAt: new Date().toISOString(),
|
||||
departments: [
|
||||
{
|
||||
name: 'ENGINEERING',
|
||||
key: 'engineering',
|
||||
roles: [
|
||||
{
|
||||
id: 'x',
|
||||
title: 'Design Engineer',
|
||||
department: 'Engineering',
|
||||
location: 'San Francisco',
|
||||
applyUrl: 'https://jobs.ashbyhq.com/comfy-org/x'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
function freshOutcome(droppedCount = 0): FetchOutcome {
|
||||
return {
|
||||
status: 'fresh',
|
||||
droppedCount,
|
||||
droppedRoles:
|
||||
droppedCount === 0
|
||||
? []
|
||||
: [{ title: 'Bad Role', reason: 'jobUrl: Invalid url' }],
|
||||
snapshot: {
|
||||
fetchedAt: new Date().toISOString(),
|
||||
departments: [
|
||||
{
|
||||
name: 'ENGINEERING',
|
||||
key: 'engineering',
|
||||
roles: [
|
||||
{
|
||||
id: 'x',
|
||||
title: 'Design Engineer',
|
||||
department: 'Engineering',
|
||||
location: 'San Francisco',
|
||||
applyUrl: 'https://jobs.ashbyhq.com/comfy-org/x'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('reportAshbyOutcome', () => {
|
||||
let writeSpy: ReturnType<typeof vi.spyOn>
|
||||
let summaryDir: string
|
||||
let summaryPath: string
|
||||
const originalSummary = process.env.GITHUB_STEP_SUMMARY
|
||||
|
||||
beforeEach(() => {
|
||||
resetAshbyReporterForTests()
|
||||
writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
|
||||
summaryDir = mkdtempSync(join(tmpdir(), 'ashby-summary-'))
|
||||
summaryPath = join(summaryDir, 'summary.md')
|
||||
writeFileSync(summaryPath, '')
|
||||
process.env.GITHUB_STEP_SUMMARY = summaryPath
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
writeSpy.mockRestore()
|
||||
rmSync(summaryDir, { recursive: true, force: true })
|
||||
if (originalSummary === undefined) delete process.env.GITHUB_STEP_SUMMARY
|
||||
else process.env.GITHUB_STEP_SUMMARY = originalSummary
|
||||
})
|
||||
|
||||
it('emits nothing on a clean fresh outcome', () => {
|
||||
reportAshbyOutcome(freshOutcome(0))
|
||||
expect(writeSpy).not.toHaveBeenCalled()
|
||||
expect(readFileSync(summaryPath, 'utf8')).toContain('Fresh')
|
||||
})
|
||||
|
||||
it('emits exactly one set of annotations across repeated calls', () => {
|
||||
reportAshbyOutcome(freshOutcome(1))
|
||||
reportAshbyOutcome(freshOutcome(1))
|
||||
expect(writeSpy).toHaveBeenCalledTimes(1)
|
||||
const annotation = writeSpy.mock.calls[0]![0] as string
|
||||
expect(annotation).toContain('::warning title=Ashby: dropped 1 invalid')
|
||||
expect(readFileSync(summaryPath, 'utf8')).toContain('Dropped')
|
||||
})
|
||||
|
||||
it('emits ::error for auth failures in a stale outcome', () => {
|
||||
reportAshbyOutcome({
|
||||
status: 'stale',
|
||||
reason: 'HTTP 401 Unauthorized',
|
||||
snapshot: baseSnapshot()
|
||||
})
|
||||
const annotation = writeSpy.mock.calls[0]![0] as string
|
||||
expect(annotation).toContain('::error title=Ashby authentication failed')
|
||||
})
|
||||
|
||||
it('emits ::warning for missing-env stale outcomes', () => {
|
||||
reportAshbyOutcome({
|
||||
status: 'stale',
|
||||
reason: 'missing WEBSITE_ASHBY_API_KEY or WEBSITE_ASHBY_JOB_BOARD_NAME',
|
||||
snapshot: baseSnapshot()
|
||||
})
|
||||
const annotation = writeSpy.mock.calls[0]![0] as string
|
||||
expect(annotation).toContain('::warning title=Ashby integration')
|
||||
})
|
||||
|
||||
it('emits ::error for a failed outcome and writes no fresh-only sections', () => {
|
||||
reportAshbyOutcome({ status: 'failed', reason: 'HTTP 500 Server Error' })
|
||||
const annotation = writeSpy.mock.calls[0]![0] as string
|
||||
expect(annotation).toContain('::error title=Ashby fetch failed')
|
||||
expect(readFileSync(summaryPath, 'utf8')).toContain('Failed')
|
||||
})
|
||||
|
||||
it('does not throw when GITHUB_STEP_SUMMARY is not set', () => {
|
||||
delete process.env.GITHUB_STEP_SUMMARY
|
||||
expect(() => reportAshbyOutcome(freshOutcome(0))).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -1,113 +0,0 @@
|
||||
import { appendFileSync } from 'node:fs'
|
||||
|
||||
import type { FetchOutcome } from './ashby'
|
||||
|
||||
let hasReported = false
|
||||
|
||||
export function resetAshbyReporterForTests(): void {
|
||||
hasReported = false
|
||||
}
|
||||
|
||||
export function reportAshbyOutcome(outcome: FetchOutcome): void {
|
||||
if (hasReported) return
|
||||
hasReported = true
|
||||
|
||||
const lines = buildAnnotations(outcome)
|
||||
for (const line of lines) {
|
||||
process.stdout.write(`${line}\n`)
|
||||
}
|
||||
|
||||
const summaryPath = process.env.GITHUB_STEP_SUMMARY
|
||||
if (summaryPath) {
|
||||
try {
|
||||
appendFileSync(summaryPath, buildStepSummary(outcome))
|
||||
} catch {
|
||||
// Writing the summary is best-effort; do not fail the build if the
|
||||
// runner's summary file is unavailable (e.g. local dev).
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildAnnotations(outcome: FetchOutcome): string[] {
|
||||
if (outcome.status === 'fresh') {
|
||||
if (outcome.droppedCount === 0) return []
|
||||
const roleCount = outcome.droppedCount === 1 ? 'role' : 'roles'
|
||||
const drops = outcome.droppedRoles
|
||||
.map((d) => ` - ${d.title ? `"${d.title}"` : '(untitled)'}: ${d.reason}`)
|
||||
.join('%0A')
|
||||
return [
|
||||
`::warning title=Ashby: dropped ${outcome.droppedCount} invalid ${roleCount}::Dropped roles:%0A${drops}%0A%0AAction items:%0A 1. Fix the posting in Ashby admin (e.g. assign a department, fix the URL).%0A 2. If the v1 schema is too strict for a legitimate case, relax the field in apps/website/src/utils/ashby.schema.ts and add a test.%0A 3. These roles will not appear on the careers page until fixed.`
|
||||
]
|
||||
}
|
||||
|
||||
if (outcome.status === 'stale') {
|
||||
return [staleAnnotation(outcome.reason)]
|
||||
}
|
||||
|
||||
return [
|
||||
`::error title=Ashby fetch failed and no snapshot is available::Cannot build careers page without data.%0A%0AReason: ${escapeAnnotation(outcome.reason)}%0A%0AAction items:%0A 1. Run \`pnpm --filter @comfyorg/website ashby:refresh-snapshot\` locally with a valid WEBSITE_ASHBY_API_KEY.%0A 2. Commit apps/website/src/data/ashby-roles.snapshot.json.%0A 3. Push and re-run CI.`
|
||||
]
|
||||
}
|
||||
|
||||
function staleAnnotation(reason: string): string {
|
||||
const escaped = escapeAnnotation(reason)
|
||||
if (reason.startsWith('missing ')) {
|
||||
return `::warning title=Ashby integration::${escaped}. Falling back to committed snapshot.%0A%0AAction items:%0A 1. If you're a contributor without key access, this is expected. The snapshot will be used.%0A 2. If this is CI, check that the \`WEBSITE_ASHBY_API_KEY\` secret exists in the repo and is referenced in .github/workflows/ci-website-build.yaml.`
|
||||
}
|
||||
if (reason.startsWith('HTTP 401') || reason.startsWith('HTTP 403')) {
|
||||
return `::error title=Ashby authentication failed::${escaped}. The WEBSITE_ASHBY_API_KEY is missing, invalid, or revoked. Build continues with the last-known-good snapshot.%0A%0AAction items:%0A 1. Open Ashby → Settings → API Keys and confirm the key is active.%0A 2. Update the \`WEBSITE_ASHBY_API_KEY\` secret in GitHub Actions and Vercel.%0A 3. Re-run this workflow.`
|
||||
}
|
||||
if (reason.startsWith('envelope')) {
|
||||
return `::error title=Ashby schema mismatch::${escaped}. The Ashby API contract has likely changed. Build continues with the snapshot, but future updates will fail until the schema is fixed.%0A%0AAction items:%0A 1. Check https://developers.ashbyhq.com/reference for API changelog.%0A 2. Update apps/website/src/utils/ashby.schema.ts to match the new shape.`
|
||||
}
|
||||
return `::warning title=Ashby API unavailable::${escaped}. Using last-known-good snapshot.%0A%0AAction items:%0A 1. Check https://status.ashbyhq.com%0A 2. Re-run this workflow once Ashby is healthy.`
|
||||
}
|
||||
|
||||
function escapeAnnotation(value: string): string {
|
||||
return value.replace(/\r?\n/g, '%0A').replace(/\r/g, '%0D')
|
||||
}
|
||||
|
||||
function buildStepSummary(outcome: FetchOutcome): string {
|
||||
const header = '## 💼 Careers (Ashby)\n'
|
||||
const rows: Array<[string, string]> = []
|
||||
|
||||
if (outcome.status === 'fresh') {
|
||||
rows.push(['Status', '✅ Fresh (fetched from Ashby)'])
|
||||
rows.push([
|
||||
'Roles',
|
||||
String(
|
||||
outcome.snapshot.departments.reduce((n, d) => n + d.roles.length, 0)
|
||||
)
|
||||
])
|
||||
rows.push(['Dropped', String(outcome.droppedCount)])
|
||||
} else if (outcome.status === 'stale') {
|
||||
rows.push(['Status', '⚠️ Stale (using snapshot — Ashby fetch failed)'])
|
||||
rows.push([
|
||||
'Roles',
|
||||
String(
|
||||
outcome.snapshot.departments.reduce((n, d) => n + d.roles.length, 0)
|
||||
)
|
||||
])
|
||||
rows.push(['Reason', outcome.reason])
|
||||
rows.push(['Snapshot age', describeSnapshotAge(outcome.snapshot.fetchedAt)])
|
||||
} else {
|
||||
rows.push(['Status', '❌ Failed (no snapshot available)'])
|
||||
rows.push(['Reason', outcome.reason])
|
||||
}
|
||||
|
||||
const table =
|
||||
'| | |\n|---|---|\n' +
|
||||
rows.map(([k, v]) => `| **${k}** | ${v} |`).join('\n') +
|
||||
'\n'
|
||||
|
||||
return `${header}${table}\n`
|
||||
}
|
||||
|
||||
function describeSnapshotAge(fetchedAt: string): string {
|
||||
const fetched = new Date(fetchedAt).getTime()
|
||||
if (Number.isNaN(fetched)) return 'unknown'
|
||||
const days = Math.floor((Date.now() - fetched) / 86_400_000)
|
||||
if (days <= 0) return 'today'
|
||||
if (days === 1) return '1 day'
|
||||
return `${days} days`
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const AshbyJobPostingSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
department: z.string().optional(),
|
||||
location: z.string().optional(),
|
||||
isListed: z.boolean(),
|
||||
jobUrl: z.string().url(),
|
||||
applyUrl: z.string().url().optional()
|
||||
})
|
||||
|
||||
export const AshbyJobBoardResponseSchema = z.object({
|
||||
apiVersion: z.literal('1'),
|
||||
jobs: z.array(z.unknown())
|
||||
})
|
||||
|
||||
export type AshbyJobPosting = z.infer<typeof AshbyJobPostingSchema>
|
||||
@@ -1,328 +0,0 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { AshbyJobPosting } from './ashby.schema'
|
||||
import type { RolesSnapshot } from '../data/roles'
|
||||
|
||||
import { fetchRolesForBuild, resetAshbyFetcherForTests } from './ashby'
|
||||
|
||||
const BASE_URL = 'https://ashby.test'
|
||||
const BOARD = 'comfy-org'
|
||||
const KEY = 'abc-123-secret'
|
||||
|
||||
function validJob(overrides: Partial<AshbyJobPosting> = {}): unknown {
|
||||
return {
|
||||
title: 'Design Engineer',
|
||||
department: 'Engineering',
|
||||
location: 'San Francisco',
|
||||
isListed: true,
|
||||
jobUrl: 'https://jobs.ashbyhq.com/comfy-org/design-engineer',
|
||||
applyUrl: 'https://jobs.ashbyhq.com/comfy-org/design-engineer/apply',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function response(body: unknown, init: Partial<ResponseInit> = {}): Response {
|
||||
const base: ResponseInit = {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' }
|
||||
}
|
||||
return new Response(JSON.stringify(body), { ...base, ...init })
|
||||
}
|
||||
|
||||
function makeSnapshot(roleCount = 2): RolesSnapshot {
|
||||
const roles = Array.from({ length: roleCount }, (_, i) => ({
|
||||
id: `snapshot-role-${i}`,
|
||||
title: `Snapshot Role ${i}`,
|
||||
department: 'Engineering',
|
||||
location: 'San Francisco',
|
||||
applyUrl: `https://jobs.ashbyhq.com/comfy-org/snapshot-${i}`
|
||||
}))
|
||||
return {
|
||||
fetchedAt: '2026-04-01T00:00:00.000Z',
|
||||
departments: [{ name: 'ENGINEERING', key: 'engineering', roles }]
|
||||
}
|
||||
}
|
||||
|
||||
function withSnapshotDir(snapshot: RolesSnapshot | null): URL {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'ashby-test-'))
|
||||
const file = join(dir, 'ashby-roles.snapshot.json')
|
||||
if (snapshot) writeFileSync(file, JSON.stringify(snapshot))
|
||||
return pathToFileURL(file)
|
||||
}
|
||||
|
||||
describe('fetchRolesForBuild', () => {
|
||||
const savedApiKey = process.env.WEBSITE_ASHBY_API_KEY
|
||||
const savedBoardName = process.env.WEBSITE_ASHBY_JOB_BOARD_NAME
|
||||
|
||||
beforeEach(() => {
|
||||
resetAshbyFetcherForTests()
|
||||
delete process.env.WEBSITE_ASHBY_API_KEY
|
||||
delete process.env.WEBSITE_ASHBY_JOB_BOARD_NAME
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
process.env.WEBSITE_ASHBY_API_KEY = savedApiKey
|
||||
process.env.WEBSITE_ASHBY_JOB_BOARD_NAME = savedBoardName
|
||||
})
|
||||
|
||||
it('returns fresh when the API succeeds', async () => {
|
||||
const fetchImpl = vi.fn(async () =>
|
||||
response({ apiVersion: '1', jobs: [validJob()] })
|
||||
)
|
||||
const outcome = await fetchRolesForBuild({
|
||||
apiKey: KEY,
|
||||
boardName: BOARD,
|
||||
baseUrl: BASE_URL,
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch
|
||||
})
|
||||
expect(outcome.status).toBe('fresh')
|
||||
if (outcome.status !== 'fresh') return
|
||||
expect(outcome.droppedCount).toBe(0)
|
||||
expect(outcome.snapshot.departments).toHaveLength(1)
|
||||
expect(outcome.snapshot.departments[0]!.roles[0]!.applyUrl).toMatch(
|
||||
/design-engineer\/apply$/
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to jobUrl when applyUrl is missing and keeps the role', async () => {
|
||||
const job = validJob()
|
||||
delete (job as Record<string, unknown>).applyUrl
|
||||
const fetchImpl = vi.fn(async () =>
|
||||
response({ apiVersion: '1', jobs: [job] })
|
||||
)
|
||||
const outcome = await fetchRolesForBuild({
|
||||
apiKey: KEY,
|
||||
boardName: BOARD,
|
||||
baseUrl: BASE_URL,
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch
|
||||
})
|
||||
expect(outcome.status).toBe('fresh')
|
||||
if (outcome.status !== 'fresh') return
|
||||
expect(outcome.snapshot.departments[0]!.roles[0]!.applyUrl).toBe(
|
||||
'https://jobs.ashbyhq.com/comfy-org/design-engineer'
|
||||
)
|
||||
})
|
||||
|
||||
it('drops invalid roles individually and keeps the valid ones', async () => {
|
||||
const snapshotUrl = withSnapshotDir(makeSnapshot())
|
||||
const fetchImpl = vi.fn(async () =>
|
||||
response({
|
||||
apiVersion: '1',
|
||||
jobs: [validJob(), validJob({ title: 'Bad Role', jobUrl: 'not-a-url' })]
|
||||
})
|
||||
)
|
||||
const outcome = await fetchRolesForBuild({
|
||||
apiKey: KEY,
|
||||
boardName: BOARD,
|
||||
baseUrl: BASE_URL,
|
||||
snapshotUrl,
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch
|
||||
})
|
||||
expect(outcome.status).toBe('fresh')
|
||||
if (outcome.status !== 'fresh') return
|
||||
expect(outcome.droppedCount).toBe(1)
|
||||
expect(outcome.droppedRoles[0]!.title).toBe('Bad Role')
|
||||
expect(outcome.snapshot.departments[0]!.roles).toHaveLength(1)
|
||||
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('renders an empty-but-fresh outcome when hiring is paused', async () => {
|
||||
const snapshotUrl = withSnapshotDir(makeSnapshot())
|
||||
const fetchImpl = vi.fn(async () => response({ apiVersion: '1', jobs: [] }))
|
||||
const outcome = await fetchRolesForBuild({
|
||||
apiKey: KEY,
|
||||
boardName: BOARD,
|
||||
baseUrl: BASE_URL,
|
||||
snapshotUrl,
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch
|
||||
})
|
||||
expect(outcome.status).toBe('fresh')
|
||||
if (outcome.status !== 'fresh') return
|
||||
expect(outcome.snapshot.departments).toEqual([])
|
||||
expect(outcome.droppedCount).toBe(0)
|
||||
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('normalizes missing department and location to safe defaults', async () => {
|
||||
const snapshotUrl = withSnapshotDir(makeSnapshot())
|
||||
const job = validJob()
|
||||
delete (job as Record<string, unknown>).department
|
||||
delete (job as Record<string, unknown>).location
|
||||
const fetchImpl = vi.fn(async () =>
|
||||
response({ apiVersion: '1', jobs: [job] })
|
||||
)
|
||||
const outcome = await fetchRolesForBuild({
|
||||
apiKey: KEY,
|
||||
boardName: BOARD,
|
||||
baseUrl: BASE_URL,
|
||||
snapshotUrl,
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch
|
||||
})
|
||||
expect(outcome.status).toBe('fresh')
|
||||
if (outcome.status !== 'fresh') return
|
||||
const [department] = outcome.snapshot.departments
|
||||
expect(department?.name).toBe('OTHER')
|
||||
expect(department?.roles[0]?.location).toBe('Remote')
|
||||
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('filters out roles with isListed=false', async () => {
|
||||
const snapshotUrl = withSnapshotDir(makeSnapshot())
|
||||
const fetchImpl = vi.fn(async () =>
|
||||
response({
|
||||
apiVersion: '1',
|
||||
jobs: [validJob(), validJob({ title: 'Hidden', isListed: false })]
|
||||
})
|
||||
)
|
||||
const outcome = await fetchRolesForBuild({
|
||||
apiKey: KEY,
|
||||
boardName: BOARD,
|
||||
baseUrl: BASE_URL,
|
||||
snapshotUrl,
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch
|
||||
})
|
||||
expect(outcome.status).toBe('fresh')
|
||||
if (outcome.status !== 'fresh') return
|
||||
const titles = outcome.snapshot.departments.flatMap((d) =>
|
||||
d.roles.map((r) => r.title)
|
||||
)
|
||||
expect(titles).not.toContain('Hidden')
|
||||
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('returns stale with missing env when the snapshot is present', async () => {
|
||||
const snapshot = makeSnapshot()
|
||||
const snapshotUrl = withSnapshotDir(snapshot)
|
||||
const fetchImpl = vi.fn()
|
||||
const outcome = await fetchRolesForBuild({
|
||||
snapshotUrl,
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch
|
||||
})
|
||||
expect(outcome.status).toBe('stale')
|
||||
if (outcome.status !== 'stale') return
|
||||
expect(outcome.reason).toMatch(/^missing /)
|
||||
expect(fetchImpl).not.toHaveBeenCalled()
|
||||
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('returns failed when both env and snapshot are missing', async () => {
|
||||
const snapshotUrl = withSnapshotDir(null)
|
||||
const outcome = await fetchRolesForBuild({
|
||||
snapshotUrl,
|
||||
fetchImpl: vi.fn() as unknown as typeof fetch
|
||||
})
|
||||
expect(outcome.status).toBe('failed')
|
||||
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('does not retry on HTTP 401', async () => {
|
||||
const snapshotUrl = withSnapshotDir(makeSnapshot())
|
||||
const fetchImpl = vi.fn(async () => response({}, { status: 401 }))
|
||||
const outcome = await fetchRolesForBuild({
|
||||
apiKey: KEY,
|
||||
boardName: BOARD,
|
||||
baseUrl: BASE_URL,
|
||||
snapshotUrl,
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch
|
||||
})
|
||||
expect(outcome.status).toBe('stale')
|
||||
if (outcome.status !== 'stale') return
|
||||
expect(outcome.reason).toMatch(/^HTTP 401/)
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1)
|
||||
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('retries 5xx up to the configured limit then falls back to snapshot', async () => {
|
||||
const snapshotUrl = withSnapshotDir(makeSnapshot())
|
||||
const fetchImpl = vi.fn(async () => response({}, { status: 503 }))
|
||||
const sleep = vi.fn(async () => undefined)
|
||||
const outcome = await fetchRolesForBuild({
|
||||
apiKey: KEY,
|
||||
boardName: BOARD,
|
||||
baseUrl: BASE_URL,
|
||||
snapshotUrl,
|
||||
retryDelaysMs: [1, 1, 1],
|
||||
sleep,
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch
|
||||
})
|
||||
expect(outcome.status).toBe('stale')
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(4)
|
||||
expect(sleep).toHaveBeenCalledTimes(3)
|
||||
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('falls back to snapshot on envelope schema mismatch', async () => {
|
||||
const snapshotUrl = withSnapshotDir(makeSnapshot())
|
||||
const fetchImpl = vi.fn(async () => response({ apiVersion: '2', jobs: [] }))
|
||||
const outcome = await fetchRolesForBuild({
|
||||
apiKey: KEY,
|
||||
boardName: BOARD,
|
||||
baseUrl: BASE_URL,
|
||||
snapshotUrl,
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch
|
||||
})
|
||||
expect(outcome.status).toBe('stale')
|
||||
if (outcome.status !== 'stale') return
|
||||
expect(outcome.reason).toMatch(/^envelope schema/)
|
||||
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('memoizes within a single process', async () => {
|
||||
const fetchImpl = vi.fn(async () =>
|
||||
response({ apiVersion: '1', jobs: [validJob()] })
|
||||
)
|
||||
const opts = {
|
||||
apiKey: KEY,
|
||||
boardName: BOARD,
|
||||
baseUrl: BASE_URL,
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch
|
||||
}
|
||||
const [a, b] = await Promise.all([
|
||||
fetchRolesForBuild(opts),
|
||||
fetchRolesForBuild(opts)
|
||||
])
|
||||
expect(a).toBe(b)
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('never writes to the snapshot file on success', async () => {
|
||||
const snapshot = makeSnapshot()
|
||||
const snapshotUrl = withSnapshotDir(snapshot)
|
||||
const before = new URL(snapshotUrl.href)
|
||||
const fs = await import('node:fs')
|
||||
const initial = fs.readFileSync(before).toString()
|
||||
const fetchImpl = vi.fn(async () =>
|
||||
response({ apiVersion: '1', jobs: [validJob()] })
|
||||
)
|
||||
await fetchRolesForBuild({
|
||||
apiKey: KEY,
|
||||
boardName: BOARD,
|
||||
baseUrl: BASE_URL,
|
||||
snapshotUrl,
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch
|
||||
})
|
||||
const after = fs.readFileSync(before).toString()
|
||||
expect(after).toBe(initial)
|
||||
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('does not retry on 4xx auth failures for 403', async () => {
|
||||
const snapshotUrl = withSnapshotDir(makeSnapshot())
|
||||
const fetchImpl = vi.fn(async () => response({}, { status: 403 }))
|
||||
await fetchRolesForBuild({
|
||||
apiKey: KEY,
|
||||
boardName: BOARD,
|
||||
baseUrl: BASE_URL,
|
||||
snapshotUrl,
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch
|
||||
})
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1)
|
||||
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
|
||||
})
|
||||
})
|
||||
@@ -1,299 +0,0 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
|
||||
import type { AshbyJobPosting } from './ashby.schema'
|
||||
import type { Department, Role, RolesSnapshot } from '../data/roles'
|
||||
|
||||
import bundledSnapshot from '../data/ashby-roles.snapshot.json' with { type: 'json' }
|
||||
import {
|
||||
AshbyJobBoardResponseSchema,
|
||||
AshbyJobPostingSchema
|
||||
} from './ashby.schema'
|
||||
|
||||
const DEFAULT_BASE_URL = 'https://api.ashbyhq.com'
|
||||
const DEFAULT_TIMEOUT_MS = 10_000
|
||||
const RETRY_DELAYS_MS = [1_000, 2_000, 4_000]
|
||||
|
||||
export interface DroppedRole {
|
||||
title: string
|
||||
reason: string
|
||||
}
|
||||
|
||||
export type FetchOutcome =
|
||||
| {
|
||||
status: 'fresh'
|
||||
snapshot: RolesSnapshot
|
||||
droppedCount: number
|
||||
droppedRoles: DroppedRole[]
|
||||
}
|
||||
| { status: 'stale'; snapshot: RolesSnapshot; reason: string }
|
||||
| { status: 'failed'; reason: string }
|
||||
|
||||
interface FetchRolesOptions {
|
||||
apiKey?: string
|
||||
boardName?: string
|
||||
baseUrl?: string
|
||||
timeoutMs?: number
|
||||
retryDelaysMs?: readonly number[]
|
||||
fetchImpl?: typeof fetch
|
||||
snapshotUrl?: URL
|
||||
sleep?: (ms: number) => Promise<void>
|
||||
}
|
||||
|
||||
let inflight: Promise<FetchOutcome> | undefined
|
||||
|
||||
export function resetAshbyFetcherForTests(): void {
|
||||
inflight = undefined
|
||||
}
|
||||
|
||||
export function fetchRolesForBuild(
|
||||
options: FetchRolesOptions = {}
|
||||
): Promise<FetchOutcome> {
|
||||
inflight ??= doFetchRolesForBuild(options)
|
||||
return inflight
|
||||
}
|
||||
|
||||
async function doFetchRolesForBuild(
|
||||
options: FetchRolesOptions
|
||||
): Promise<FetchOutcome> {
|
||||
const apiKey = options.apiKey ?? process.env.WEBSITE_ASHBY_API_KEY
|
||||
const boardName =
|
||||
options.boardName ?? process.env.WEBSITE_ASHBY_JOB_BOARD_NAME
|
||||
|
||||
if (!apiKey || !boardName) {
|
||||
return fallback(
|
||||
'missing WEBSITE_ASHBY_API_KEY or WEBSITE_ASHBY_JOB_BOARD_NAME',
|
||||
options.snapshotUrl
|
||||
)
|
||||
}
|
||||
|
||||
const result = await tryFetchAndParse(apiKey, boardName, options)
|
||||
if (result.kind === 'ok') {
|
||||
return {
|
||||
status: 'fresh',
|
||||
snapshot: {
|
||||
fetchedAt: new Date().toISOString(),
|
||||
departments: result.departments
|
||||
},
|
||||
droppedCount: result.droppedRoles.length,
|
||||
droppedRoles: result.droppedRoles
|
||||
}
|
||||
}
|
||||
|
||||
return fallback(result.reason, options.snapshotUrl)
|
||||
}
|
||||
|
||||
async function fallback(
|
||||
reason: string,
|
||||
snapshotUrl: URL | undefined
|
||||
): Promise<FetchOutcome> {
|
||||
const snapshot = await readSnapshot(snapshotUrl)
|
||||
if (snapshot) return { status: 'stale', snapshot, reason }
|
||||
return { status: 'failed', reason }
|
||||
}
|
||||
|
||||
interface FetchOk {
|
||||
kind: 'ok'
|
||||
departments: Department[]
|
||||
droppedRoles: DroppedRole[]
|
||||
}
|
||||
|
||||
interface FetchErr {
|
||||
kind: 'err'
|
||||
reason: string
|
||||
}
|
||||
|
||||
async function tryFetchAndParse(
|
||||
apiKey: string,
|
||||
boardName: string,
|
||||
options: FetchRolesOptions
|
||||
): Promise<FetchOk | FetchErr> {
|
||||
const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL
|
||||
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
||||
const retryDelaysMs = options.retryDelaysMs ?? RETRY_DELAYS_MS
|
||||
const fetchImpl = options.fetchImpl ?? fetch
|
||||
const sleep = options.sleep ?? defaultSleep
|
||||
|
||||
const url = `${baseUrl}/posting-api/job-board/${encodeURIComponent(
|
||||
boardName
|
||||
)}?includeCompensation=false`
|
||||
const authHeader = `Basic ${Buffer.from(`${apiKey}:`).toString('base64')}`
|
||||
|
||||
let lastReason = 'unknown error'
|
||||
for (let attempt = 0; attempt <= retryDelaysMs.length; attempt++) {
|
||||
if (attempt > 0) await sleep(retryDelaysMs[attempt - 1])
|
||||
|
||||
const response = await callOnce(fetchImpl, url, authHeader, timeoutMs)
|
||||
if (response.kind === 'err') {
|
||||
lastReason = response.reason
|
||||
if (!response.retryable) return response
|
||||
continue
|
||||
}
|
||||
|
||||
const envelope = AshbyJobBoardResponseSchema.safeParse(response.body)
|
||||
if (!envelope.success) {
|
||||
return {
|
||||
kind: 'err',
|
||||
reason: `envelope schema validation failed: ${envelope.error.issues
|
||||
.map((i) => `${i.path.join('.') || '<root>'}: ${i.message}`)
|
||||
.join('; ')}`
|
||||
}
|
||||
}
|
||||
|
||||
return parseRoles(envelope.data.jobs)
|
||||
}
|
||||
|
||||
return { kind: 'err', reason: lastReason }
|
||||
}
|
||||
|
||||
type CallResponse =
|
||||
| { kind: 'ok'; body: unknown }
|
||||
| { kind: 'err'; reason: string; retryable: boolean }
|
||||
|
||||
async function callOnce(
|
||||
fetchImpl: typeof fetch,
|
||||
url: string,
|
||||
authHeader: string,
|
||||
timeoutMs: number
|
||||
): Promise<CallResponse> {
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
||||
try {
|
||||
const res = await fetchImpl(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: authHeader,
|
||||
Accept: 'application/json; version=1'
|
||||
},
|
||||
signal: controller.signal
|
||||
})
|
||||
if (res.ok) {
|
||||
return { kind: 'ok', body: await res.json() }
|
||||
}
|
||||
const retryable =
|
||||
res.status === 429 || (res.status >= 500 && res.status < 600)
|
||||
return {
|
||||
kind: 'err',
|
||||
reason: `HTTP ${res.status} ${res.statusText || ''}`.trim(),
|
||||
retryable
|
||||
}
|
||||
} catch (error) {
|
||||
const reason =
|
||||
error instanceof Error
|
||||
? `network error: ${error.message}`
|
||||
: 'network error'
|
||||
return { kind: 'err', reason, retryable: true }
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
function parseRoles(jobs: readonly unknown[]): FetchOk {
|
||||
const valid: AshbyJobPosting[] = []
|
||||
const droppedRoles: DroppedRole[] = []
|
||||
|
||||
for (const raw of jobs) {
|
||||
const parsed = AshbyJobPostingSchema.safeParse(raw)
|
||||
if (!parsed.success) {
|
||||
droppedRoles.push({
|
||||
title: extractTitle(raw),
|
||||
reason: parsed.error.issues
|
||||
.map((i) => `${i.path.join('.') || '<root>'}: ${i.message}`)
|
||||
.join('; ')
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (!parsed.data.isListed) continue
|
||||
valid.push(parsed.data)
|
||||
}
|
||||
|
||||
return { kind: 'ok', departments: groupByDepartment(valid), droppedRoles }
|
||||
}
|
||||
|
||||
function extractTitle(raw: unknown): string {
|
||||
if (
|
||||
raw !== null &&
|
||||
typeof raw === 'object' &&
|
||||
'title' in raw &&
|
||||
typeof (raw as { title: unknown }).title === 'string'
|
||||
) {
|
||||
return (raw as { title: string }).title
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const DEFAULT_DEPARTMENT = 'Other'
|
||||
const DEFAULT_LOCATION = 'Remote'
|
||||
|
||||
function groupByDepartment(jobs: readonly AshbyJobPosting[]): Department[] {
|
||||
const byKey = new Map<string, Department>()
|
||||
for (const job of jobs) {
|
||||
const displayDepartment = normalizeDepartment(job.department)
|
||||
const name = displayDepartment.toUpperCase()
|
||||
const key = slugify(name)
|
||||
const existing = byKey.get(key)
|
||||
const role = toDomainRole(job, displayDepartment)
|
||||
if (existing) {
|
||||
existing.roles.push(role)
|
||||
} else {
|
||||
byKey.set(key, { name, key, roles: [role] })
|
||||
}
|
||||
}
|
||||
return [...byKey.values()].sort((a, b) => a.name.localeCompare(b.name))
|
||||
}
|
||||
|
||||
function toDomainRole(job: AshbyJobPosting, department: string): Role {
|
||||
const applyUrl = job.applyUrl ?? job.jobUrl
|
||||
return {
|
||||
id: createHash('sha1').update(applyUrl).digest('hex').slice(0, 16),
|
||||
title: job.title,
|
||||
department: capitalize(department),
|
||||
location: (job.location ?? '').trim() || DEFAULT_LOCATION,
|
||||
applyUrl
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeDepartment(raw: string | undefined): string {
|
||||
const trimmed = (raw ?? '').trim()
|
||||
return trimmed.length > 0 ? trimmed : DEFAULT_DEPARTMENT
|
||||
}
|
||||
|
||||
function slugify(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
}
|
||||
|
||||
function capitalize(value: string): string {
|
||||
return value.charAt(0).toUpperCase() + value.slice(1).toLowerCase()
|
||||
}
|
||||
|
||||
async function readSnapshot(
|
||||
snapshotUrl: URL | undefined
|
||||
): Promise<RolesSnapshot | null> {
|
||||
if (!snapshotUrl) {
|
||||
return isRolesSnapshot(bundledSnapshot) ? bundledSnapshot : null
|
||||
}
|
||||
try {
|
||||
const text = await readFile(snapshotUrl, 'utf8')
|
||||
const parsed: unknown = JSON.parse(text)
|
||||
if (isRolesSnapshot(parsed)) return parsed
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function isRolesSnapshot(value: unknown): value is RolesSnapshot {
|
||||
if (value === null || typeof value !== 'object') return false
|
||||
const candidate = value as { fetchedAt?: unknown; departments?: unknown }
|
||||
return (
|
||||
typeof candidate.fetchedAt === 'string' &&
|
||||
Array.isArray(candidate.departments)
|
||||
)
|
||||
}
|
||||
|
||||
function defaultSleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
@@ -8,10 +8,8 @@
|
||||
"include": [
|
||||
"src/**/*",
|
||||
"e2e/**/*",
|
||||
"scripts/**/*",
|
||||
"astro.config.ts",
|
||||
"playwright.config.ts",
|
||||
"vitest.config.ts"
|
||||
"playwright.config.ts"
|
||||
],
|
||||
"exclude": ["src/**/*.stories.ts"],
|
||||
"references": [{ "path": "./tsconfig.stories.json" }]
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['src/**/*.{test,spec}.ts'],
|
||||
globals: false
|
||||
}
|
||||
})
|
||||
@@ -250,26 +250,6 @@ export class ModelLibrarySidebarTab extends SidebarTab {
|
||||
}
|
||||
}
|
||||
|
||||
type MediaFilterKind = 'image' | 'video' | 'audio' | '3d'
|
||||
type MediaFilterLabel = 'Image' | 'Video' | 'Audio' | '3D'
|
||||
|
||||
function getMediaFilterLabel(
|
||||
filter: MediaFilterKind | MediaFilterLabel
|
||||
): MediaFilterLabel {
|
||||
switch (filter) {
|
||||
case 'image':
|
||||
return 'Image'
|
||||
case 'video':
|
||||
return 'Video'
|
||||
case 'audio':
|
||||
return 'Audio'
|
||||
case '3d':
|
||||
return '3D'
|
||||
default:
|
||||
return filter
|
||||
}
|
||||
}
|
||||
|
||||
export class AssetsSidebarTab extends SidebarTab {
|
||||
// --- Tab navigation ---
|
||||
public readonly generatedTab: Locator
|
||||
@@ -281,13 +261,6 @@ export class AssetsSidebarTab extends SidebarTab {
|
||||
// --- Search & filter ---
|
||||
public readonly searchInput: Locator
|
||||
public readonly settingsButton: Locator
|
||||
public readonly filterButton: Locator
|
||||
|
||||
// --- Filter menu checkboxes (cloud-only, shown inside filter popover) ---
|
||||
public readonly filterImageCheckbox: Locator
|
||||
public readonly filterVideoCheckbox: Locator
|
||||
public readonly filterAudioCheckbox: Locator
|
||||
public readonly filter3DCheckbox: Locator
|
||||
|
||||
// --- View mode ---
|
||||
public readonly listViewOption: Locator
|
||||
@@ -296,8 +269,6 @@ export class AssetsSidebarTab extends SidebarTab {
|
||||
// --- Sort options (cloud-only, shown inside settings popover) ---
|
||||
public readonly sortNewestFirst: Locator
|
||||
public readonly sortOldestFirst: Locator
|
||||
public readonly sortLongestFirst: Locator
|
||||
public readonly sortFastestFirst: Locator
|
||||
|
||||
// --- Asset cards ---
|
||||
public readonly assetCards: Locator
|
||||
@@ -328,17 +299,10 @@ export class AssetsSidebarTab extends SidebarTab {
|
||||
)
|
||||
this.searchInput = page.getByPlaceholder('Search Assets...')
|
||||
this.settingsButton = page.getByRole('button', { name: 'View settings' })
|
||||
this.filterButton = page.getByRole('button', { name: 'Filter by' })
|
||||
this.filterImageCheckbox = page.getByRole('checkbox', { name: 'Image' })
|
||||
this.filterVideoCheckbox = page.getByRole('checkbox', { name: 'Video' })
|
||||
this.filterAudioCheckbox = page.getByRole('checkbox', { name: 'Audio' })
|
||||
this.filter3DCheckbox = page.getByRole('checkbox', { name: '3D' })
|
||||
this.listViewOption = page.getByText('List view')
|
||||
this.gridViewOption = page.getByText('Grid view')
|
||||
this.sortNewestFirst = page.getByText('Newest first')
|
||||
this.sortOldestFirst = page.getByText('Oldest first')
|
||||
this.sortLongestFirst = page.getByText('Generation time (longest first)')
|
||||
this.sortFastestFirst = page.getByText('Generation time (fastest first)')
|
||||
this.assetCards = page
|
||||
.getByRole('button')
|
||||
.and(page.locator('[data-selected]'))
|
||||
@@ -370,12 +334,6 @@ export class AssetsSidebarTab extends SidebarTab {
|
||||
return this.page.getByText(title)
|
||||
}
|
||||
|
||||
filterCheckbox(filter: MediaFilterKind | MediaFilterLabel) {
|
||||
return this.page.getByRole('checkbox', {
|
||||
name: getMediaFilterLabel(filter)
|
||||
})
|
||||
}
|
||||
|
||||
getAssetCardByName(name: string) {
|
||||
return this.assetCards.filter({ hasText: name })
|
||||
}
|
||||
@@ -425,29 +383,6 @@ export class AssetsSidebarTab extends SidebarTab {
|
||||
.waitFor({ state: 'visible', timeout: 3000 })
|
||||
}
|
||||
|
||||
async openFilterMenu() {
|
||||
await this.dismissToasts()
|
||||
await this.filterButton.click()
|
||||
await this.filterCheckbox('Image').waitFor({
|
||||
state: 'visible',
|
||||
timeout: 3000
|
||||
})
|
||||
}
|
||||
|
||||
async toggleMediaTypeFilter(
|
||||
filter: MediaFilterKind | MediaFilterLabel
|
||||
): Promise<void> {
|
||||
const checkbox = this.filterCheckbox(filter)
|
||||
const before = await checkbox.getAttribute('aria-checked')
|
||||
await checkbox.click()
|
||||
const expected = before === 'true' ? 'false' : 'true'
|
||||
await expect(checkbox).toHaveAttribute('aria-checked', expected)
|
||||
}
|
||||
|
||||
async getAssetCardOrder(): Promise<string[]> {
|
||||
return await this.assetCards.allInnerTexts()
|
||||
}
|
||||
|
||||
async rightClickAsset(name: string) {
|
||||
const card = this.getAssetCardByName(name)
|
||||
await card.click({ button: 'right' })
|
||||
|
||||
@@ -10,8 +10,6 @@ export class SubgraphBreadcrumbPanel {
|
||||
readonly activeItem: Locator
|
||||
readonly missingNodesIcon: Locator
|
||||
readonly blueprintTag: Locator
|
||||
readonly rootItem: Locator
|
||||
readonly rootBlueprintTag: Locator
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.root = page.getByTestId(TestIds.breadcrumb.subgraph)
|
||||
@@ -25,10 +23,10 @@ export class SubgraphBreadcrumbPanel {
|
||||
TestIds.breadcrumb.missingNodesIcon
|
||||
)
|
||||
this.blueprintTag = this.root.getByTestId(TestIds.breadcrumb.blueprintTag)
|
||||
this.rootItem = page.getByTestId(TestIds.breadcrumb.item('root'))
|
||||
this.rootBlueprintTag = this.rootItem.getByTestId(
|
||||
TestIds.breadcrumb.blueprintTag
|
||||
)
|
||||
}
|
||||
|
||||
rootItem(): Locator {
|
||||
return this.page.getByTestId(TestIds.breadcrumb.item('root'))
|
||||
}
|
||||
|
||||
subgraphItem(subgraphId: string): Locator {
|
||||
|
||||
@@ -7,56 +7,26 @@ const jobsListRoutePattern = /\/api\/jobs(?:\?.*)?$/
|
||||
const inputFilesRoutePattern = /\/internal\/files\/input(?:\?.*)?$/
|
||||
const historyRoutePattern = /\/api\/history$/
|
||||
|
||||
/**
|
||||
* Media kinds supported by the assets sidebar filter UI. The string values
|
||||
* match what the backend stores on `preview_output.mediaType` (`images` is
|
||||
* intentionally plural to match existing API conventions; the others are
|
||||
* singular as emitted by `useMediaAssetGalleryStore`).
|
||||
*
|
||||
* The sidebar filter ultimately matches on the filename extension, so the
|
||||
* fixture also picks an extension-appropriate filename for each kind.
|
||||
*/
|
||||
export type MediaKindFixture = 'images' | 'video' | 'audio' | '3D'
|
||||
|
||||
const DEFAULT_EXTENSION: Record<MediaKindFixture, string> = {
|
||||
images: 'png',
|
||||
video: 'mp4',
|
||||
audio: 'wav',
|
||||
'3D': 'glb'
|
||||
}
|
||||
|
||||
/** Factory to create a mock completed job with preview output. */
|
||||
export function createMockJob(
|
||||
overrides: Partial<RawJobListItem> & {
|
||||
id: string
|
||||
/**
|
||||
* Optional shorthand to set both `preview_output.mediaType` and an
|
||||
* extension-appropriate filename. Ignored when `preview_output` is also
|
||||
* supplied via `overrides`.
|
||||
*/
|
||||
mediaKind?: MediaKindFixture
|
||||
}
|
||||
overrides: Partial<RawJobListItem> & { id: string }
|
||||
): RawJobListItem {
|
||||
const { mediaKind, ...rest } = overrides
|
||||
const now = Date.now()
|
||||
const extension = mediaKind ? DEFAULT_EXTENSION[mediaKind] : 'png'
|
||||
const mediaType = mediaKind ?? 'images'
|
||||
|
||||
return {
|
||||
status: 'completed',
|
||||
create_time: now,
|
||||
execution_start_time: now,
|
||||
execution_end_time: now + 5000,
|
||||
preview_output: {
|
||||
filename: `output_${rest.id}.${extension}`,
|
||||
filename: `output_${overrides.id}.png`,
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType
|
||||
mediaType: 'images'
|
||||
},
|
||||
outputs_count: 1,
|
||||
priority: 0,
|
||||
...rest
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,46 +54,6 @@ export function createMockJobs(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create one job per requested media kind, in the order supplied. Jobs share
|
||||
* a stable timestamp ordering (newer first) so callers can rely on the result
|
||||
* order when mediaType filters are inactive.
|
||||
*/
|
||||
export function createMixedMediaJobs(
|
||||
kinds: MediaKindFixture[]
|
||||
): RawJobListItem[] {
|
||||
const now = Date.now()
|
||||
return kinds.map((kind, i) =>
|
||||
createMockJob({
|
||||
id: `${kind}-${String(i + 1).padStart(3, '0')}`,
|
||||
mediaKind: kind,
|
||||
create_time: now - i * 60_000,
|
||||
execution_start_time: now - i * 60_000,
|
||||
execution_end_time: now - i * 60_000 + 5000
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create jobs with explicit `(create_time, execution duration)` pairs so that
|
||||
* sort assertions for newest/oldest and longest/fastest are unambiguous.
|
||||
*
|
||||
* Each spec entry yields a job whose `execution_end_time - execution_start_time`
|
||||
* equals `durationMs`. The first spec becomes id `job-001`, etc.
|
||||
*/
|
||||
export function createJobsWithExecutionTimes(
|
||||
specs: ReadonlyArray<{ createTime: number; durationMs: number }>
|
||||
): RawJobListItem[] {
|
||||
return specs.map((spec, i) =>
|
||||
createMockJob({
|
||||
id: `job-${String(i + 1).padStart(3, '0')}`,
|
||||
create_time: spec.createTime,
|
||||
execution_start_time: spec.createTime,
|
||||
execution_end_time: spec.createTime + spec.durationMs
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/** Create mock imported file names with various media types. */
|
||||
export function createMockImportedFiles(count: number): string[] {
|
||||
const extensions = ['png', 'jpg', 'mp4', 'wav', 'glb', 'txt']
|
||||
|
||||
@@ -1,232 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type {
|
||||
Asset,
|
||||
JobsListResponse,
|
||||
ListAssetsResponse
|
||||
} from '@comfyorg/ingest-types'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import { createMixedMediaJobs } from '@e2e/fixtures/helpers/AssetsHelper'
|
||||
|
||||
// The assets sidebar's media-type filter menu only renders in cloud mode
|
||||
// (`MediaAssetFilterBar.vue` gates `MediaAssetFilterButton` behind `isCloud`).
|
||||
// We tag tests `@cloud` so they run against the cloud Playwright project,
|
||||
// and register both `/api/assets` and `/api/jobs` route handlers as auto
|
||||
// fixtures — Playwright runs auto fixtures before the `comfyPage` fixture's
|
||||
// internal `setup()`, so the page first-loads with mocks already in place.
|
||||
// See cloud-asset-default.spec.ts for the same pattern.
|
||||
|
||||
const MIXED_JOBS = createMixedMediaJobs(['images', 'video', 'audio', '3D'])
|
||||
|
||||
// MediaAssetCard renders the filename *without* extension via
|
||||
// getFilenameDetails(...).filename, so card-text matching uses the basename.
|
||||
function expectCardText(index: number): string {
|
||||
const filename = MIXED_JOBS[index]?.preview_output?.filename
|
||||
if (!filename) {
|
||||
throw new Error(
|
||||
`MIXED_JOBS[${index}].preview_output.filename is missing — ` +
|
||||
'createMixedMediaJobs contract changed.'
|
||||
)
|
||||
}
|
||||
return filename.replace(/\.[^.]+$/, '')
|
||||
}
|
||||
|
||||
const imageCardName = expectCardText(0)
|
||||
const videoCardName = expectCardText(1)
|
||||
const audioCardName = expectCardText(2)
|
||||
const threeDCardName = expectCardText(3)
|
||||
|
||||
function makeAssetsResponse(assets: Asset[]): ListAssetsResponse {
|
||||
return { assets, total: assets.length, has_more: false }
|
||||
}
|
||||
|
||||
function makeJobsResponseBody() {
|
||||
return {
|
||||
jobs: MIXED_JOBS,
|
||||
pagination: {
|
||||
offset: 0,
|
||||
limit: MIXED_JOBS.length,
|
||||
total: MIXED_JOBS.length,
|
||||
has_more: false
|
||||
}
|
||||
} satisfies {
|
||||
jobs: unknown[]
|
||||
pagination: JobsListResponse['pagination']
|
||||
}
|
||||
}
|
||||
|
||||
const test = comfyPageFixture.extend<{
|
||||
stubCloudAssets: void
|
||||
stubJobs: void
|
||||
stubInputFiles: void
|
||||
}>({
|
||||
stubCloudAssets: [
|
||||
async ({ page }, use) => {
|
||||
const pattern = '**/api/assets?*'
|
||||
await page.route(pattern, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(makeAssetsResponse([]))
|
||||
})
|
||||
)
|
||||
await use()
|
||||
await page.unroute(pattern)
|
||||
},
|
||||
{ auto: true }
|
||||
],
|
||||
stubJobs: [
|
||||
async ({ page }, use) => {
|
||||
const pattern = /\/api\/jobs(?:\?.*)?$/
|
||||
await page.route(pattern, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(makeJobsResponseBody())
|
||||
})
|
||||
)
|
||||
await use()
|
||||
await page.unroute(pattern)
|
||||
},
|
||||
{ auto: true }
|
||||
],
|
||||
stubInputFiles: [
|
||||
async ({ page }, use) => {
|
||||
const pattern = /\/internal\/files\/input(?:\?.*)?$/
|
||||
await page.route(pattern, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([])
|
||||
})
|
||||
)
|
||||
await use()
|
||||
await page.unroute(pattern)
|
||||
},
|
||||
{ auto: true }
|
||||
]
|
||||
})
|
||||
|
||||
test.describe('Assets sidebar - media type filter', { tag: '@cloud' }, () => {
|
||||
test('Filter menu opens and exposes all four media-type checkboxes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets(MIXED_JOBS.length)
|
||||
|
||||
await tab.openFilterMenu()
|
||||
|
||||
await expect(tab.filterImageCheckbox).toBeVisible()
|
||||
await expect(tab.filterVideoCheckbox).toBeVisible()
|
||||
await expect(tab.filterAudioCheckbox).toBeVisible()
|
||||
await expect(tab.filter3DCheckbox).toBeVisible()
|
||||
for (const cb of [
|
||||
tab.filterImageCheckbox,
|
||||
tab.filterVideoCheckbox,
|
||||
tab.filterAudioCheckbox,
|
||||
tab.filter3DCheckbox
|
||||
]) {
|
||||
await expect(cb).toHaveAttribute('aria-checked', 'false')
|
||||
}
|
||||
})
|
||||
|
||||
test('Selecting only "Image" hides non-image assets', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets(MIXED_JOBS.length)
|
||||
|
||||
await tab.openFilterMenu()
|
||||
await tab.toggleMediaTypeFilter('image')
|
||||
|
||||
await expect(tab.assetCards).toHaveCount(1)
|
||||
await expect(tab.getAssetCardByName(imageCardName)).toBeVisible()
|
||||
await expect(tab.getAssetCardByName(videoCardName)).toHaveCount(0)
|
||||
await expect(tab.getAssetCardByName(audioCardName)).toHaveCount(0)
|
||||
await expect(tab.getAssetCardByName(threeDCardName)).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('Selecting only "Video" hides non-video assets', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets(MIXED_JOBS.length)
|
||||
|
||||
await tab.openFilterMenu()
|
||||
await tab.toggleMediaTypeFilter('video')
|
||||
|
||||
await expect(tab.assetCards).toHaveCount(1)
|
||||
await expect(tab.getAssetCardByName(videoCardName)).toBeVisible()
|
||||
})
|
||||
|
||||
test('Selecting only "Audio" hides non-audio assets', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets(MIXED_JOBS.length)
|
||||
|
||||
await tab.openFilterMenu()
|
||||
await tab.toggleMediaTypeFilter('audio')
|
||||
|
||||
await expect(tab.assetCards).toHaveCount(1)
|
||||
await expect(tab.getAssetCardByName(audioCardName)).toBeVisible()
|
||||
})
|
||||
|
||||
test('Selecting only "3D" hides non-3D assets', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets(MIXED_JOBS.length)
|
||||
|
||||
await tab.openFilterMenu()
|
||||
await tab.toggleMediaTypeFilter('3d')
|
||||
|
||||
await expect(tab.assetCards).toHaveCount(1)
|
||||
await expect(tab.getAssetCardByName(threeDCardName)).toBeVisible()
|
||||
})
|
||||
|
||||
test('Multiple filters combine via OR (image + video)', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets(MIXED_JOBS.length)
|
||||
|
||||
await tab.openFilterMenu()
|
||||
await tab.toggleMediaTypeFilter('image')
|
||||
await tab.toggleMediaTypeFilter('video')
|
||||
|
||||
await expect(tab.assetCards).toHaveCount(2)
|
||||
await expect(tab.getAssetCardByName(imageCardName)).toBeVisible()
|
||||
await expect(tab.getAssetCardByName(videoCardName)).toBeVisible()
|
||||
await expect(tab.getAssetCardByName(audioCardName)).toHaveCount(0)
|
||||
await expect(tab.getAssetCardByName(threeDCardName)).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('Unchecking the active filter restores previously hidden cards', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets(MIXED_JOBS.length)
|
||||
|
||||
await tab.openFilterMenu()
|
||||
await tab.toggleMediaTypeFilter('image')
|
||||
await expect(tab.assetCards).toHaveCount(1)
|
||||
|
||||
await tab.toggleMediaTypeFilter('image')
|
||||
|
||||
// TODO(#11635): the 3D preview card does not remount after a filter
|
||||
// toggle restores it (only image/video/audio reappear). Image, video,
|
||||
// and audio cover the restoration path; once #11635 is fixed, add the
|
||||
// 3D card back to this assertion list.
|
||||
await expect(tab.getAssetCardByName(imageCardName)).toBeVisible({
|
||||
timeout: 10_000
|
||||
})
|
||||
await expect(tab.getAssetCardByName(videoCardName)).toBeVisible()
|
||||
await expect(tab.getAssetCardByName(audioCardName)).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -1,206 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type {
|
||||
Asset,
|
||||
JobsListResponse,
|
||||
ListAssetsResponse
|
||||
} from '@comfyorg/ingest-types'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import { createJobsWithExecutionTimes } from '@e2e/fixtures/helpers/AssetsHelper'
|
||||
|
||||
// The assets sidebar's sort options live inside the settings popover and are
|
||||
// only rendered in cloud mode (`MediaAssetFilterBar.vue`:
|
||||
// `:show-sort-options="isCloud"`). We tag tests `@cloud` so they run against
|
||||
// the cloud Playwright project, and register `/api/assets`, `/api/jobs`, and
|
||||
// `/internal/files/input` route handlers as auto fixtures — Playwright runs
|
||||
// auto fixtures before the `comfyPage` fixture's internal `setup()`, so the
|
||||
// page first-loads with mocks already in place.
|
||||
|
||||
// Three jobs whose `(create_time, duration)` axes are intentionally
|
||||
// misaligned so newest/oldest and longest/fastest sorts produce *different*
|
||||
// orderings — preventing false-pass tests where one ordering accidentally
|
||||
// satisfies another.
|
||||
//
|
||||
// spec create_time duration (ms)
|
||||
// ----------------------------------------
|
||||
// job-001 1000 5000 (oldest, mid duration)
|
||||
// job-002 2000 10000 (mid age, longest)
|
||||
// job-003 3000 3000 (newest, shortest)
|
||||
const SORT_JOBS = createJobsWithExecutionTimes([
|
||||
{ createTime: 1000, durationMs: 5000 },
|
||||
{ createTime: 2000, durationMs: 10000 },
|
||||
{ createTime: 3000, durationMs: 3000 }
|
||||
])
|
||||
|
||||
// MediaAssetCard renders the filename *without* extension via
|
||||
// getFilenameDetails(...).filename, so card-text matching uses the basename.
|
||||
const NAME_BY_ID: Record<string, string> = {
|
||||
'job-001': 'output_job-001',
|
||||
'job-002': 'output_job-002',
|
||||
'job-003': 'output_job-003'
|
||||
}
|
||||
|
||||
function makeAssetsResponse(assets: Asset[]): ListAssetsResponse {
|
||||
return { assets, total: assets.length, has_more: false }
|
||||
}
|
||||
|
||||
function makeJobsResponseBody() {
|
||||
return {
|
||||
jobs: SORT_JOBS,
|
||||
pagination: {
|
||||
offset: 0,
|
||||
limit: SORT_JOBS.length,
|
||||
total: SORT_JOBS.length,
|
||||
has_more: false
|
||||
}
|
||||
} satisfies {
|
||||
jobs: unknown[]
|
||||
pagination: JobsListResponse['pagination']
|
||||
}
|
||||
}
|
||||
|
||||
const test = comfyPageFixture.extend<{
|
||||
stubCloudAssets: void
|
||||
stubJobs: void
|
||||
stubInputFiles: void
|
||||
}>({
|
||||
stubCloudAssets: [
|
||||
async ({ page }, use) => {
|
||||
const pattern = '**/api/assets?*'
|
||||
await page.route(pattern, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(makeAssetsResponse([]))
|
||||
})
|
||||
)
|
||||
await use()
|
||||
await page.unroute(pattern)
|
||||
},
|
||||
{ auto: true }
|
||||
],
|
||||
stubJobs: [
|
||||
async ({ page }, use) => {
|
||||
const pattern = /\/api\/jobs(?:\?.*)?$/
|
||||
await page.route(pattern, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(makeJobsResponseBody())
|
||||
})
|
||||
)
|
||||
await use()
|
||||
await page.unroute(pattern)
|
||||
},
|
||||
{ auto: true }
|
||||
],
|
||||
stubInputFiles: [
|
||||
async ({ page }, use) => {
|
||||
const pattern = /\/internal\/files\/input(?:\?.*)?$/
|
||||
await page.route(pattern, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([])
|
||||
})
|
||||
)
|
||||
await use()
|
||||
await page.unroute(pattern)
|
||||
},
|
||||
{ auto: true }
|
||||
]
|
||||
})
|
||||
|
||||
test.describe('Assets sidebar - sort options', { tag: '@cloud' }, () => {
|
||||
test('Settings menu exposes all four sort options in cloud mode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets(SORT_JOBS.length)
|
||||
|
||||
await tab.openSettingsMenu()
|
||||
|
||||
await expect(tab.sortNewestFirst).toBeVisible()
|
||||
await expect(tab.sortOldestFirst).toBeVisible()
|
||||
await expect(tab.sortLongestFirst).toBeVisible()
|
||||
await expect(tab.sortFastestFirst).toBeVisible()
|
||||
})
|
||||
|
||||
test('Default order is newest first (descending create_time)', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets(SORT_JOBS.length)
|
||||
|
||||
// Cards should appear in the order: job-003, job-002, job-001
|
||||
await expect(tab.assetCards.nth(0)).toContainText(NAME_BY_ID['job-003'])
|
||||
await expect(tab.assetCards.nth(1)).toContainText(NAME_BY_ID['job-002'])
|
||||
await expect(tab.assetCards.nth(2)).toContainText(NAME_BY_ID['job-001'])
|
||||
})
|
||||
|
||||
test('"Oldest first" reverses the order', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets(SORT_JOBS.length)
|
||||
|
||||
await tab.openSettingsMenu()
|
||||
await tab.sortOldestFirst.click()
|
||||
|
||||
await expect(tab.assetCards.nth(0)).toContainText(NAME_BY_ID['job-001'])
|
||||
await expect(tab.assetCards.nth(1)).toContainText(NAME_BY_ID['job-002'])
|
||||
await expect(tab.assetCards.nth(2)).toContainText(NAME_BY_ID['job-003'])
|
||||
})
|
||||
|
||||
test('"Longest first" puts the slowest job at the top', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets(SORT_JOBS.length)
|
||||
|
||||
await tab.openSettingsMenu()
|
||||
await tab.sortLongestFirst.click()
|
||||
|
||||
// Expected: job-002 (10s), job-001 (5s), job-003 (3s)
|
||||
await expect(tab.assetCards.nth(0)).toContainText(NAME_BY_ID['job-002'])
|
||||
await expect(tab.assetCards.nth(1)).toContainText(NAME_BY_ID['job-001'])
|
||||
await expect(tab.assetCards.nth(2)).toContainText(NAME_BY_ID['job-003'])
|
||||
})
|
||||
|
||||
test('"Fastest first" puts the quickest job at the top', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets(SORT_JOBS.length)
|
||||
|
||||
await tab.openSettingsMenu()
|
||||
await tab.sortFastestFirst.click()
|
||||
|
||||
// Expected: job-003 (3s), job-001 (5s), job-002 (10s)
|
||||
await expect(tab.assetCards.nth(0)).toContainText(NAME_BY_ID['job-003'])
|
||||
await expect(tab.assetCards.nth(1)).toContainText(NAME_BY_ID['job-001'])
|
||||
await expect(tab.assetCards.nth(2)).toContainText(NAME_BY_ID['job-002'])
|
||||
})
|
||||
|
||||
test('Sort persists when the search input is edited', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets(SORT_JOBS.length)
|
||||
|
||||
await tab.openSettingsMenu()
|
||||
await tab.sortOldestFirst.click()
|
||||
|
||||
// Type a query that matches all three jobs, then clear it; sort order
|
||||
// must remain "oldest first".
|
||||
await tab.searchInput.fill('output_job')
|
||||
await tab.searchInput.fill('')
|
||||
|
||||
await expect(tab.assetCards.nth(0)).toContainText(NAME_BY_ID['job-001'])
|
||||
await expect(tab.assetCards.nth(2)).toContainText(NAME_BY_ID['job-003'])
|
||||
})
|
||||
})
|
||||
@@ -772,119 +772,3 @@ test.describe('Assets sidebar - delete confirmation', () => {
|
||||
await expect(tab.assetCards).toHaveCount(initialCount)
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 12. Media type filter (cloud-only)
|
||||
// ==========================================================================
|
||||
|
||||
const MIXED_MEDIA_JOBS: RawJobListItem[] = [
|
||||
createMockJob({
|
||||
id: 'job-image',
|
||||
create_time: 1000,
|
||||
execution_start_time: 1000,
|
||||
execution_end_time: 1010,
|
||||
preview_output: {
|
||||
filename: 'photo.png',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
},
|
||||
outputs_count: 1
|
||||
}),
|
||||
createMockJob({
|
||||
id: 'job-video',
|
||||
create_time: 2000,
|
||||
execution_start_time: 2000,
|
||||
execution_end_time: 2010,
|
||||
preview_output: {
|
||||
filename: 'clip.mp4',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '2',
|
||||
mediaType: 'video'
|
||||
},
|
||||
outputs_count: 1
|
||||
}),
|
||||
createMockJob({
|
||||
id: 'job-audio',
|
||||
create_time: 3000,
|
||||
execution_start_time: 3000,
|
||||
execution_end_time: 3010,
|
||||
preview_output: {
|
||||
filename: 'track.mp3',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '3',
|
||||
mediaType: 'audio'
|
||||
},
|
||||
outputs_count: 1
|
||||
})
|
||||
]
|
||||
|
||||
// Filter button is guarded by isCloud (compile-time). The cloud CI project
|
||||
// cannot use comfyPageFixture (auth required). Enable once cloud E2E infra
|
||||
// supports authenticated comfyPage setup.
|
||||
test.describe('Assets sidebar - media type filter', () => {
|
||||
test.fixme(true, 'Requires DISTRIBUTION=cloud build with auth bypass')
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(MIXED_MEDIA_JOBS)
|
||||
await comfyPage.assets.mockInputFiles([])
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Filter menu shows media type options', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
await tab.openFilterMenu()
|
||||
|
||||
await expect(tab.filterCheckbox('Image')).toBeVisible()
|
||||
await expect(tab.filterCheckbox('Video')).toBeVisible()
|
||||
await expect(tab.filterCheckbox('Audio')).toBeVisible()
|
||||
await expect(tab.filterCheckbox('3D')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Unchecking image filter hides image assets', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
const initialCount = tab.assetCards
|
||||
await expect(
|
||||
initialCount,
|
||||
'All three mixed-media jobs should render'
|
||||
).toHaveCount(3)
|
||||
|
||||
// Open filter menu and enable only image filter (selecting a filter
|
||||
// restricts to that type only, hiding unselected types)
|
||||
await tab.openFilterMenu()
|
||||
await tab.filterCheckbox('Image').click()
|
||||
|
||||
// Only the image asset should remain
|
||||
await expect(tab.assetCards).toHaveCount(1, { timeout: 5000 })
|
||||
await expect(tab.getAssetCardByName('photo.png')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Re-enabling filter restores hidden assets', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
const initialCount = await tab.assetCards.count()
|
||||
|
||||
// Enable image filter to restrict to images only
|
||||
await tab.openFilterMenu()
|
||||
await tab.filterCheckbox('Image').click()
|
||||
await expect(tab.assetCards).toHaveCount(1, { timeout: 5000 })
|
||||
|
||||
// Uncheck image filter to remove all filters (restores all assets)
|
||||
await tab.filterCheckbox('Image').click()
|
||||
await expect(tab.assetCards).toHaveCount(initialCount, { timeout: 5000 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -251,7 +251,7 @@ test.describe('Subgraph Breadcrumb', { tag: ['@subgraph'] }, () => {
|
||||
await comfyPage.workflow.loadWorkflow(NESTED_WORKFLOW)
|
||||
await enterNestedSubgraphs(comfyPage)
|
||||
|
||||
await expect(subgraphBreadcrumb.panel.rootItem).toBeAttached()
|
||||
await expect(subgraphBreadcrumb.panel.rootItem()).toBeAttached()
|
||||
await expect(
|
||||
subgraphBreadcrumb.panel.subgraphItem(SUBGRAPH_2_ID)
|
||||
).toContainText(SUBGRAPH_2_NAME)
|
||||
@@ -265,65 +265,12 @@ test.describe('Subgraph Breadcrumb', { tag: ['@subgraph'] }, () => {
|
||||
)
|
||||
})
|
||||
|
||||
test(
|
||||
'shows Blueprint tag only when editing a published blueprint',
|
||||
{
|
||||
tag: ['@vue-nodes']
|
||||
},
|
||||
async ({ comfyPage, subgraphBreadcrumb }) => {
|
||||
const { panel } = subgraphBreadcrumb
|
||||
const blueprintName = `bp-breadcrumb-tag-${Date.now()}`
|
||||
|
||||
await test.step('Tag is not shown on a normal workflow', async () => {
|
||||
await expect(panel.blueprintTag).toHaveCount(0)
|
||||
})
|
||||
|
||||
await test.step('Convert to Subgraph', async () => {
|
||||
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
await comfyPage.contextMenu.openForVueNode(ksampler.header)
|
||||
await comfyPage.contextMenu.clickMenuItemExact('Convert to Subgraph')
|
||||
})
|
||||
|
||||
const subgraphNodeId = await comfyPage.subgraph.findSubgraphNodeId()
|
||||
const subgraphNode =
|
||||
await comfyPage.nodeOps.getNodeRefById(subgraphNodeId)
|
||||
|
||||
await test.step('Unpublished subgraph does not show the blueprint tag', async () => {
|
||||
await subgraphNode.centerOnNode()
|
||||
await comfyPage.vueNodes.enterSubgraph(subgraphNodeId)
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
await expect(panel.blueprintTag).toHaveCount(0)
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
})
|
||||
|
||||
await test.step('Publish the subgraph as a blueprint', async () => {
|
||||
await subgraphNode.click('title')
|
||||
await comfyPage.subgraph.publishSubgraph(blueprintName)
|
||||
})
|
||||
|
||||
await test.step('Open the blueprint from the node library', async () => {
|
||||
const tab = comfyPage.menu.nodeLibraryTabV2
|
||||
await tab.open()
|
||||
await tab.getFolder('My Blueprints').click()
|
||||
await tab.getFolder('User').click()
|
||||
|
||||
const blueprintNode = tab.getNode(blueprintName)
|
||||
await expect(blueprintNode).toBeVisible()
|
||||
await blueprintNode.getByRole('button', { name: 'Edit' }).click()
|
||||
})
|
||||
|
||||
await test.step('Blueprint tag renders on the root breadcrumb', async () => {
|
||||
await expect(panel.rootBlueprintTag).toBeVisible()
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
test('collapses when overflowing and expands when there is room', async ({
|
||||
comfyPage,
|
||||
subgraphBreadcrumb
|
||||
}) => {
|
||||
const { panel } = subgraphBreadcrumb
|
||||
const rootItem = panel.rootItem
|
||||
const rootItem = panel.rootItem()
|
||||
const subgraph2Item = panel.subgraphItem(SUBGRAPH_2_ID)
|
||||
const subgraph3Item = panel.subgraphItem(SUBGRAPH_3_ID)
|
||||
const originalViewport = comfyPage.page.viewportSize()
|
||||
|
||||
@@ -196,48 +196,6 @@ test.describe('Image Crop', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
})
|
||||
.toStrictEqual(before)
|
||||
})
|
||||
|
||||
test('Selecting a ratio preset auto-enables aspect ratio lock', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
|
||||
await expect(
|
||||
node.getByRole('button', { name: 'Lock aspect ratio' }),
|
||||
'lock button should start in unlocked state'
|
||||
).toBeVisible()
|
||||
|
||||
await node.getByRole('combobox').click()
|
||||
await comfyPage.page
|
||||
.getByRole('option', { name: '4:3', exact: true })
|
||||
.click()
|
||||
|
||||
await expect(
|
||||
node.getByRole('button', { name: 'Unlock aspect ratio' }),
|
||||
'selecting a preset should auto-lock the ratio'
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Unlocking after a preset selection shows Custom in ratio dropdown', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
|
||||
await node.getByRole('combobox').click()
|
||||
await comfyPage.page
|
||||
.getByRole('option', { name: '1:1', exact: true })
|
||||
.click()
|
||||
await expect(
|
||||
node.getByRole('button', { name: 'Unlock aspect ratio' })
|
||||
).toBeVisible()
|
||||
|
||||
await node.getByRole('button', { name: 'Unlock aspect ratio' }).click()
|
||||
|
||||
await expect(
|
||||
node.getByRole('combobox'),
|
||||
'dropdown should revert to Custom after unlock'
|
||||
).toContainText('Custom')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe(
|
||||
@@ -855,339 +813,6 @@ test.describe('Image Crop', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
await comfyPage.page.unroute('**/api/view**')
|
||||
}
|
||||
})
|
||||
|
||||
test('Selecting 16:9 ratio adjusts crop height proportionally', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('2')
|
||||
await setCropBounds(comfyPage, 2, {
|
||||
x: 50,
|
||||
y: 50,
|
||||
width: 360,
|
||||
height: 360
|
||||
})
|
||||
|
||||
const before = await getCropValue(comfyPage, 2)
|
||||
if (!before) throw new Error('missing crop')
|
||||
const expectedHeight = Math.round(before.width / (16 / 9))
|
||||
|
||||
await node.getByRole('combobox').click()
|
||||
await comfyPage.page
|
||||
.getByRole('option', { name: '16:9', exact: true })
|
||||
.click()
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const v = await getCropValue(comfyPage, 2)
|
||||
if (!v || v.width !== before.width) return false
|
||||
return Math.abs(v.height - expectedHeight) <= 2
|
||||
},
|
||||
{ message: '16:9 ratio should adjust crop height proportionally' }
|
||||
)
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
test('Selecting Custom from ratio dropdown unlocks aspect ratio', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('2')
|
||||
await setCropBounds(comfyPage, 2, {
|
||||
x: 50,
|
||||
y: 50,
|
||||
width: 200,
|
||||
height: 200
|
||||
})
|
||||
|
||||
await node.getByRole('combobox').click()
|
||||
await comfyPage.page
|
||||
.getByRole('option', { name: '1:1', exact: true })
|
||||
.click()
|
||||
await expect(
|
||||
node
|
||||
.locator('[data-testid^="crop-resize-"]')
|
||||
.filter({ visible: true }),
|
||||
'preset selection should lock ratio and show 4 handles'
|
||||
).toHaveCount(4)
|
||||
|
||||
await node.getByRole('combobox').click()
|
||||
await comfyPage.page
|
||||
.getByRole('option', { name: 'Custom', exact: true })
|
||||
.click()
|
||||
|
||||
await expect(
|
||||
node
|
||||
.locator('[data-testid^="crop-resize-"]')
|
||||
.filter({ visible: true }),
|
||||
'selecting Custom should unlock ratio and restore 8 handles'
|
||||
).toHaveCount(8)
|
||||
})
|
||||
|
||||
test('Unlock button releases locked ratio and restores all 8 resize handles', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('2')
|
||||
|
||||
await node.getByRole('button', { name: 'Lock aspect ratio' }).click()
|
||||
|
||||
await expect(
|
||||
node
|
||||
.locator('[data-testid^="crop-resize-"]')
|
||||
.filter({ visible: true }),
|
||||
'lock should reduce handles to 4'
|
||||
).toHaveCount(4)
|
||||
await expect(
|
||||
node.getByRole('button', { name: 'Unlock aspect ratio' }),
|
||||
'lock button aria-label should update after locking'
|
||||
).toBeVisible()
|
||||
|
||||
await node.getByRole('button', { name: 'Unlock aspect ratio' }).click()
|
||||
|
||||
await expect(
|
||||
node
|
||||
.locator('[data-testid^="crop-resize-"]')
|
||||
.filter({ visible: true }),
|
||||
'unlock should restore all 8 handles'
|
||||
).toHaveCount(8)
|
||||
await expect(
|
||||
node.getByRole('button', { name: 'Lock aspect ratio' }),
|
||||
'lock button aria-label should revert after unlocking'
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Constrained resize from NW corner adjusts origin and dimensions proportionally', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('2')
|
||||
await setCropBounds(comfyPage, 2, {
|
||||
x: 150,
|
||||
y: 140,
|
||||
width: 200,
|
||||
height: 150
|
||||
})
|
||||
await node.getByRole('button', { name: 'Lock aspect ratio' }).click()
|
||||
|
||||
const before = await getCropValue(comfyPage, 2)
|
||||
if (!before) throw new Error('missing crop')
|
||||
const ratio = before.width / before.height
|
||||
|
||||
await dragOnLocator(
|
||||
comfyPage,
|
||||
node.getByTestId('crop-resize-nw'),
|
||||
-45,
|
||||
-35
|
||||
)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const v = await getCropValue(comfyPage, 2)
|
||||
if (!v) return false
|
||||
return (
|
||||
v.x < before.x &&
|
||||
v.y < before.y &&
|
||||
v.width > before.width &&
|
||||
v.height > before.height &&
|
||||
Math.abs(v.width / v.height - ratio) < 0.06
|
||||
)
|
||||
},
|
||||
{
|
||||
message:
|
||||
'constrained NW resize should grow box and maintain aspect ratio'
|
||||
}
|
||||
)
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
test('Constrained resize clamps to image boundary while maintaining ratio', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('2')
|
||||
const img = node.locator('img')
|
||||
await waitForImageNaturalSize(img)
|
||||
const { nw, nh } = await img.evaluate((el: HTMLImageElement) => ({
|
||||
nw: el.naturalWidth,
|
||||
nh: el.naturalHeight
|
||||
}))
|
||||
|
||||
await setCropBounds(comfyPage, 2, {
|
||||
x: nw - 100,
|
||||
y: nh - 75,
|
||||
width: 80,
|
||||
height: 60
|
||||
})
|
||||
await node.getByRole('button', { name: 'Lock aspect ratio' }).click()
|
||||
|
||||
const before = await getCropValue(comfyPage, 2)
|
||||
if (!before) throw new Error('missing crop')
|
||||
const initialRatio = before.width / before.height
|
||||
|
||||
await dragOnLocator(
|
||||
comfyPage,
|
||||
node.getByTestId('crop-resize-se'),
|
||||
600,
|
||||
400
|
||||
)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const v = await getCropValue(comfyPage, 2)
|
||||
if (!v) return false
|
||||
const withinBounds = v.x + v.width <= nw && v.y + v.height <= nh
|
||||
const ratio = v.width / v.height
|
||||
const ratioPreserved = Math.abs(ratio - initialRatio) < 0.05
|
||||
return withinBounds && ratioPreserved
|
||||
},
|
||||
{
|
||||
message:
|
||||
'constrained resize should stay within image boundaries and preserve aspect ratio'
|
||||
}
|
||||
)
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
test('Constrained NW corner resize clamps at top-left image boundary', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('2')
|
||||
await setCropBounds(comfyPage, 2, {
|
||||
x: 30,
|
||||
y: 25,
|
||||
width: 160,
|
||||
height: 120
|
||||
})
|
||||
await node.getByRole('button', { name: 'Lock aspect ratio' }).click()
|
||||
|
||||
await dragOnLocator(
|
||||
comfyPage,
|
||||
node.getByTestId('crop-resize-nw'),
|
||||
-400,
|
||||
-300
|
||||
)
|
||||
|
||||
await expect
|
||||
.poll(async () => (await getCropValue(comfyPage, 2))?.x ?? -1, {
|
||||
message: 'constrained NW resize should clamp x to image boundary'
|
||||
})
|
||||
.toBeGreaterThanOrEqual(0)
|
||||
await expect
|
||||
.poll(async () => (await getCropValue(comfyPage, 2))?.y ?? -1, {
|
||||
message: 'constrained NW resize should clamp y to image boundary'
|
||||
})
|
||||
.toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
test('Constrained resize enforces minimum crop size', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('2')
|
||||
await setCropBounds(comfyPage, 2, {
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 60,
|
||||
height: 60
|
||||
})
|
||||
await node.getByRole('button', { name: 'Lock aspect ratio' }).click()
|
||||
|
||||
await dragOnLocator(
|
||||
comfyPage,
|
||||
node.getByTestId('crop-resize-se'),
|
||||
-300,
|
||||
-300
|
||||
)
|
||||
|
||||
await expect
|
||||
.poll(async () => (await getCropValue(comfyPage, 2))?.width ?? 0, {
|
||||
message: 'constrained resize should respect minimum width'
|
||||
})
|
||||
.toBeGreaterThanOrEqual(MIN_CROP_SIZE)
|
||||
await expect
|
||||
.poll(async () => (await getCropValue(comfyPage, 2))?.height ?? 0, {
|
||||
message: 'constrained resize should respect minimum height'
|
||||
})
|
||||
.toBeGreaterThanOrEqual(MIN_CROP_SIZE)
|
||||
})
|
||||
|
||||
test('Incrementing X in BoundingBox moves crop box right', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('2')
|
||||
await setCropBounds(comfyPage, 2, {
|
||||
x: 100,
|
||||
y: 80,
|
||||
width: 200,
|
||||
height: 150
|
||||
})
|
||||
|
||||
const before = await getCropValue(comfyPage, 2)
|
||||
if (!before) throw new Error('missing crop')
|
||||
|
||||
await node
|
||||
.getByTestId('bounding-box-x')
|
||||
.getByTestId('increment')
|
||||
.click()
|
||||
|
||||
await expect
|
||||
.poll(async () => (await getCropValue(comfyPage, 2))?.x, {
|
||||
message: 'incrementing X should move crop right by 1'
|
||||
})
|
||||
.toBe(before.x + 1)
|
||||
})
|
||||
|
||||
test('Incrementing Width in BoundingBox increases crop width', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('2')
|
||||
await setCropBounds(comfyPage, 2, {
|
||||
x: 100,
|
||||
y: 80,
|
||||
width: 200,
|
||||
height: 150
|
||||
})
|
||||
|
||||
const before = await getCropValue(comfyPage, 2)
|
||||
if (!before) throw new Error('missing crop')
|
||||
|
||||
await node
|
||||
.getByTestId('bounding-box-width')
|
||||
.getByTestId('increment')
|
||||
.click()
|
||||
|
||||
await expect
|
||||
.poll(async () => (await getCropValue(comfyPage, 2))?.width, {
|
||||
message: 'incrementing Width should increase crop width by 1'
|
||||
})
|
||||
.toBe(before.width + 1)
|
||||
})
|
||||
|
||||
test('BoundingBox numeric inputs reflect crop position after drag', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('2')
|
||||
await setCropBounds(comfyPage, 2, {
|
||||
x: 50,
|
||||
y: 60,
|
||||
width: 200,
|
||||
height: 150
|
||||
})
|
||||
|
||||
const xInput = node.getByTestId('bounding-box-x').locator('input')
|
||||
|
||||
await expect
|
||||
.poll(async () => Number(await xInput.inputValue()), {
|
||||
message: 'X input should show initial crop x value'
|
||||
})
|
||||
.toBe(50)
|
||||
|
||||
await dragOnLocator(comfyPage, node.getByTestId('crop-overlay'), 40, 20)
|
||||
|
||||
await expect
|
||||
.poll(async () => Number(await xInput.inputValue()), {
|
||||
message: 'X input should update after crop drag'
|
||||
})
|
||||
.toBeGreaterThan(50)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.44.11",
|
||||
"version": "1.44.9",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
61
pnpm-lock.yaml
generated
61
pnpm-lock.yaml
generated
@@ -952,9 +952,6 @@ importers:
|
||||
vue:
|
||||
specifier: 'catalog:'
|
||||
version: 3.5.13(typescript@5.9.3)
|
||||
zod:
|
||||
specifier: 'catalog:'
|
||||
version: 3.25.76
|
||||
devDependencies:
|
||||
'@astrojs/check':
|
||||
specifier: 'catalog:'
|
||||
@@ -974,15 +971,9 @@ importers:
|
||||
tailwindcss:
|
||||
specifier: 'catalog:'
|
||||
version: 4.2.0
|
||||
tsx:
|
||||
specifier: 'catalog:'
|
||||
version: 4.19.4
|
||||
typescript:
|
||||
specifier: 'catalog:'
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: 'catalog:'
|
||||
version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
|
||||
packages/design-system:
|
||||
dependencies:
|
||||
@@ -14114,14 +14105,6 @@ snapshots:
|
||||
optionalDependencies:
|
||||
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
|
||||
'@vitest/mocker@4.0.16(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))':
|
||||
dependencies:
|
||||
'@vitest/spy': 4.0.16
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
|
||||
'@vitest/pretty-format@3.2.4':
|
||||
dependencies:
|
||||
tinyrainbow: 2.0.0
|
||||
@@ -14156,7 +14139,7 @@ snapshots:
|
||||
sirv: 3.0.2
|
||||
tinyglobby: 0.2.15
|
||||
tinyrainbow: 3.0.3
|
||||
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
|
||||
'@vitest/utils@3.2.4':
|
||||
dependencies:
|
||||
@@ -20337,48 +20320,6 @@ snapshots:
|
||||
- tsx
|
||||
- yaml
|
||||
|
||||
vitest@4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2):
|
||||
dependencies:
|
||||
'@vitest/expect': 4.0.16
|
||||
'@vitest/mocker': 4.0.16(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
|
||||
'@vitest/pretty-format': 4.0.16
|
||||
'@vitest/runner': 4.0.16
|
||||
'@vitest/snapshot': 4.0.16
|
||||
'@vitest/spy': 4.0.16
|
||||
'@vitest/utils': 4.0.16
|
||||
es-module-lexer: 1.7.0
|
||||
expect-type: 1.3.0
|
||||
magic-string: 0.30.21
|
||||
obug: 2.1.1
|
||||
pathe: 2.0.3
|
||||
picomatch: 4.0.3
|
||||
std-env: 3.10.0
|
||||
tinybench: 2.9.0
|
||||
tinyexec: 1.0.4
|
||||
tinyglobby: 0.2.15
|
||||
tinyrainbow: 3.0.3
|
||||
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@opentelemetry/api': 1.9.0
|
||||
'@types/node': 25.0.3
|
||||
'@vitest/ui': 4.0.16(vitest@4.0.16)
|
||||
happy-dom: 20.0.11
|
||||
jsdom: 27.4.0
|
||||
transitivePeerDependencies:
|
||||
- '@vitejs/devtools'
|
||||
- esbuild
|
||||
- jiti
|
||||
- less
|
||||
- msw
|
||||
- sass
|
||||
- sass-embedded
|
||||
- stylus
|
||||
- sugarss
|
||||
- terser
|
||||
- tsx
|
||||
- yaml
|
||||
|
||||
void-elements@3.1.0: {}
|
||||
|
||||
volar-service-css@0.0.70(@volar/language-service@2.4.28):
|
||||
|
||||
@@ -3,43 +3,19 @@
|
||||
<label class="content-center text-xs text-node-component-slot-text">
|
||||
{{ $t('boundingBox.x') }}
|
||||
</label>
|
||||
<ScrubableNumberInput
|
||||
v-model="x"
|
||||
:min="0"
|
||||
:step="1"
|
||||
:disabled
|
||||
data-testid="bounding-box-x"
|
||||
/>
|
||||
<ScrubableNumberInput v-model="x" :min="0" :step="1" :disabled />
|
||||
<label class="content-center text-xs text-node-component-slot-text">
|
||||
{{ $t('boundingBox.y') }}
|
||||
</label>
|
||||
<ScrubableNumberInput
|
||||
v-model="y"
|
||||
:min="0"
|
||||
:step="1"
|
||||
:disabled
|
||||
data-testid="bounding-box-y"
|
||||
/>
|
||||
<ScrubableNumberInput v-model="y" :min="0" :step="1" :disabled />
|
||||
<label class="content-center text-xs text-node-component-slot-text">
|
||||
{{ $t('boundingBox.width') }}
|
||||
</label>
|
||||
<ScrubableNumberInput
|
||||
v-model="width"
|
||||
:min="1"
|
||||
:step="1"
|
||||
:disabled
|
||||
data-testid="bounding-box-width"
|
||||
/>
|
||||
<ScrubableNumberInput v-model="width" :min="1" :step="1" :disabled />
|
||||
<label class="content-center text-xs text-node-component-slot-text">
|
||||
{{ $t('boundingBox.height') }}
|
||||
</label>
|
||||
<ScrubableNumberInput
|
||||
v-model="height"
|
||||
:min="1"
|
||||
:step="1"
|
||||
:disabled
|
||||
data-testid="bounding-box-height"
|
||||
/>
|
||||
<ScrubableNumberInput v-model="height" :min="1" :step="1" :disabled />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
<Tag
|
||||
v-if="item.isBlueprint"
|
||||
data-testid="subgraph-breadcrumb-blueprint-tag"
|
||||
:value="t('breadcrumbsMenu.blueprint')"
|
||||
value="Blueprint"
|
||||
severity="primary"
|
||||
/>
|
||||
<i v-if="isActive" class="pi pi-angle-down text-2xs"></i>
|
||||
|
||||
@@ -1,253 +0,0 @@
|
||||
/* eslint-disable vue/no-reserved-component-names */
|
||||
/* eslint-disable vue/no-unused-emit-declarations */
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
curveWidget: {
|
||||
linear: 'Linear',
|
||||
monotone_cubic: 'Smooth',
|
||||
step: 'Step'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const upstreamHolder = vi.hoisted(() => ({
|
||||
ref: null as { value: unknown } | null
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useUpstreamValue', async () => {
|
||||
const { ref } = await import('vue')
|
||||
return {
|
||||
useUpstreamValue: () => {
|
||||
upstreamHolder.ref = upstreamHolder.ref ?? ref<unknown>(undefined)
|
||||
return upstreamHolder.ref
|
||||
},
|
||||
singleValueExtractor: () => () => undefined
|
||||
}
|
||||
})
|
||||
|
||||
const outputsHolder = vi.hoisted(() => ({
|
||||
nodeOutputs: {} as Record<string, unknown>
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/nodeOutputStore', () => ({
|
||||
useNodeOutputStore: () => outputsHolder
|
||||
}))
|
||||
|
||||
import WidgetCurve from './WidgetCurve.vue'
|
||||
import type { CurveData } from './types'
|
||||
|
||||
const CurveEditorStub = defineComponent({
|
||||
name: 'CurveEditor',
|
||||
props: {
|
||||
modelValue: { type: Array, default: () => [] },
|
||||
disabled: { type: Boolean, default: false },
|
||||
interpolation: { type: String, default: '' },
|
||||
histogram: { type: Object, default: null }
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<div data-testid="curve-editor"
|
||||
:data-disabled="String(disabled)"
|
||||
:data-interpolation="interpolation"
|
||||
:data-has-histogram="String(!!histogram)"
|
||||
:data-points="JSON.stringify(modelValue)"
|
||||
@click="$emit('update:modelValue', [[0,0],[0.5,1],[1,0]])"
|
||||
/>
|
||||
`
|
||||
})
|
||||
|
||||
const SelectStub = defineComponent({
|
||||
name: 'Select',
|
||||
props: { modelValue: { type: String, default: '' } },
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<div data-testid="interp-select" :data-value="modelValue">
|
||||
<button
|
||||
data-testid="select-linear"
|
||||
@click="$emit('update:modelValue', 'linear')"
|
||||
>linear</button>
|
||||
<slot />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
const Passthrough = defineComponent({
|
||||
name: 'SelectPassthrough',
|
||||
template: '<slot />'
|
||||
})
|
||||
|
||||
function makeWidget(
|
||||
overrides: Partial<SimplifiedWidget<CurveData>> = {}
|
||||
): SimplifiedWidget<CurveData> {
|
||||
return {
|
||||
name: 'curve_w',
|
||||
type: 'curve',
|
||||
value: {
|
||||
points: [
|
||||
[0, 0],
|
||||
[1, 1]
|
||||
],
|
||||
interpolation: 'monotone_cubic'
|
||||
},
|
||||
options: {},
|
||||
...overrides
|
||||
} as unknown as SimplifiedWidget<CurveData>
|
||||
}
|
||||
|
||||
function setUpstream(value: CurveData | undefined) {
|
||||
if (!upstreamHolder.ref) upstreamHolder.ref = { value: undefined }
|
||||
upstreamHolder.ref.value = value
|
||||
}
|
||||
|
||||
function renderWidget(
|
||||
widget: SimplifiedWidget<CurveData>,
|
||||
initialModel: CurveData = {
|
||||
points: [
|
||||
[0, 0],
|
||||
[1, 1]
|
||||
],
|
||||
interpolation: 'monotone_cubic'
|
||||
}
|
||||
) {
|
||||
const value = ref<CurveData>(initialModel)
|
||||
const Harness = defineComponent({
|
||||
components: { WidgetCurve },
|
||||
setup: () => ({ value, widget }),
|
||||
template: '<WidgetCurve v-model="value" :widget="widget" />'
|
||||
})
|
||||
const utils = render(Harness, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
CurveEditor: CurveEditorStub,
|
||||
Select: SelectStub,
|
||||
SelectContent: Passthrough,
|
||||
SelectTrigger: Passthrough,
|
||||
SelectValue: Passthrough,
|
||||
SelectItem: Passthrough
|
||||
}
|
||||
}
|
||||
})
|
||||
return { ...utils, value }
|
||||
}
|
||||
|
||||
describe('WidgetCurve', () => {
|
||||
beforeEach(() => {
|
||||
upstreamHolder.ref = null
|
||||
outputsHolder.nodeOutputs = {}
|
||||
})
|
||||
|
||||
describe('Point forwarding', () => {
|
||||
it('forwards model points to CurveEditor', () => {
|
||||
renderWidget(makeWidget(), {
|
||||
points: [
|
||||
[0, 0],
|
||||
[0.5, 0.2],
|
||||
[1, 1]
|
||||
],
|
||||
interpolation: 'monotone_cubic'
|
||||
})
|
||||
const parsed = JSON.parse(
|
||||
screen.getByTestId('curve-editor').dataset.points!
|
||||
)
|
||||
expect(parsed).toEqual([
|
||||
[0, 0],
|
||||
[0.5, 0.2],
|
||||
[1, 1]
|
||||
])
|
||||
})
|
||||
|
||||
it('updates v-model when CurveEditor emits new points', async () => {
|
||||
const { value } = renderWidget(makeWidget())
|
||||
const user = userEvent.setup()
|
||||
await user.click(screen.getByTestId('curve-editor'))
|
||||
expect(value.value.points).toEqual([
|
||||
[0, 0],
|
||||
[0.5, 1],
|
||||
[1, 0]
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves interpolation when points change', async () => {
|
||||
const { value } = renderWidget(makeWidget(), {
|
||||
points: [
|
||||
[0, 0],
|
||||
[1, 1]
|
||||
],
|
||||
interpolation: 'linear'
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
await user.click(screen.getByTestId('curve-editor'))
|
||||
expect(value.value.interpolation).toBe('linear')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Interpolation select', () => {
|
||||
it('shows the Select when not disabled', () => {
|
||||
renderWidget(makeWidget())
|
||||
expect(screen.getByTestId('interp-select')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the Select when disabled', () => {
|
||||
renderWidget(makeWidget({ options: { disabled: true } }))
|
||||
expect(screen.queryByTestId('interp-select')).toBeNull()
|
||||
})
|
||||
|
||||
it('updates interpolation in v-model when Select emits a change', async () => {
|
||||
const { value } = renderWidget(makeWidget())
|
||||
const user = userEvent.setup()
|
||||
await user.click(screen.getByTestId('select-linear'))
|
||||
expect(value.value.interpolation).toBe('linear')
|
||||
})
|
||||
|
||||
it('preserves points when interpolation changes', async () => {
|
||||
const original: CurveData = {
|
||||
points: [
|
||||
[0, 0],
|
||||
[0.3, 0.8],
|
||||
[1, 1]
|
||||
],
|
||||
interpolation: 'monotone_cubic'
|
||||
}
|
||||
const { value } = renderWidget(makeWidget(), original)
|
||||
const user = userEvent.setup()
|
||||
await user.click(screen.getByTestId('select-linear'))
|
||||
expect(value.value.points).toEqual(original.points)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Disabled state + upstream', () => {
|
||||
it('uses upstream curve when disabled and upstream is available', () => {
|
||||
const upstream: CurveData = {
|
||||
points: [
|
||||
[0, 0],
|
||||
[0.5, 0.5],
|
||||
[1, 1]
|
||||
],
|
||||
interpolation: 'linear'
|
||||
}
|
||||
setUpstream(upstream)
|
||||
renderWidget(
|
||||
makeWidget({
|
||||
options: { disabled: true },
|
||||
linkedUpstream: { nodeId: 'n1' }
|
||||
})
|
||||
)
|
||||
const parsed = JSON.parse(
|
||||
screen.getByTestId('curve-editor').dataset.points!
|
||||
)
|
||||
expect(parsed).toEqual(upstream.points)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,77 +0,0 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import MaskEditorButton from '@/components/graph/selectionToolbox/MaskEditorButton.vue'
|
||||
|
||||
const mockExecute = vi.hoisted(() => vi.fn())
|
||||
const mockSelectionState = vi.hoisted(() => ({
|
||||
isSingleImageNode: { value: true }
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/commandStore', () => ({
|
||||
useCommandStore: () => ({ execute: mockExecute })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/graph/useSelectionState', () => ({
|
||||
useSelectionState: () => mockSelectionState
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
globalInjection: true,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
commands: {
|
||||
Comfy_MaskEditor_OpenMaskEditor: { label: 'Open in Mask Editor' }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const renderButton = () =>
|
||||
render(MaskEditorButton, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
directives: { tooltip: () => {} }
|
||||
}
|
||||
})
|
||||
|
||||
describe('MaskEditorButton', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockSelectionState.isSingleImageNode = ref(true)
|
||||
})
|
||||
|
||||
it('should render with the localized aria-label when a single image node is selected', () => {
|
||||
renderButton()
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Open in Mask Editor' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide via v-show when no single image node is selected', () => {
|
||||
mockSelectionState.isSingleImageNode = ref(false)
|
||||
renderButton()
|
||||
|
||||
const btn = screen.getByLabelText('Open in Mask Editor', {
|
||||
selector: 'button'
|
||||
})
|
||||
expect(btn.getAttribute('style') ?? '').toContain('display: none')
|
||||
})
|
||||
|
||||
it('should execute the OpenMaskEditor command on click', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderButton()
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Open in Mask Editor' })
|
||||
)
|
||||
|
||||
expect(mockExecute).toHaveBeenCalledWith('Comfy.MaskEditor.OpenMaskEditor')
|
||||
})
|
||||
})
|
||||
@@ -1,178 +0,0 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { reactive } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import BrushCursor from '@/components/maskeditor/BrushCursor.vue'
|
||||
import { BrushShape } from '@/extensions/core/maskeditor/types'
|
||||
|
||||
const initialMock = () =>
|
||||
reactive({
|
||||
brushVisible: true,
|
||||
brushPreviewGradientVisible: false,
|
||||
brushSettings: {
|
||||
type: BrushShape.Arc,
|
||||
size: 20,
|
||||
opacity: 0.7,
|
||||
hardness: 1,
|
||||
stepSize: 5
|
||||
},
|
||||
zoomRatio: 1,
|
||||
cursorPoint: { x: 100, y: 50 },
|
||||
panOffset: { x: 0, y: 0 }
|
||||
})
|
||||
|
||||
let mockStore: ReturnType<typeof initialMock>
|
||||
|
||||
vi.mock('@/stores/maskEditorStore', () => ({
|
||||
useMaskEditorStore: () => mockStore
|
||||
}))
|
||||
|
||||
const styleOf = (el: Element): string => el.getAttribute('style') ?? ''
|
||||
|
||||
const renderCursor = (containerRef?: HTMLElement) =>
|
||||
render(BrushCursor, {
|
||||
props: containerRef ? { containerRef } : {}
|
||||
})
|
||||
|
||||
const getBrushEl = (): HTMLElement => screen.getByTestId('brush-cursor')
|
||||
|
||||
const getGradientEl = (): HTMLElement =>
|
||||
screen.getByTestId('brush-cursor-gradient')
|
||||
|
||||
describe('BrushCursor', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
document.body.innerHTML = ''
|
||||
mockStore = initialMock()
|
||||
})
|
||||
|
||||
describe('opacity', () => {
|
||||
it('should be 1 when brushVisible is true', () => {
|
||||
renderCursor()
|
||||
expect(styleOf(getBrushEl())).toContain('opacity: 1')
|
||||
})
|
||||
|
||||
it('should be 0 when brushVisible is false', () => {
|
||||
mockStore.brushVisible = false
|
||||
renderCursor()
|
||||
expect(styleOf(getBrushEl())).toContain('opacity: 0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('size and shape', () => {
|
||||
it('should compute size as 2 * effectiveBrushSize * zoomRatio', () => {
|
||||
// size=20, hardness=1 → effective=20; zoom=2 → diameter = 80
|
||||
mockStore.brushSettings.size = 20
|
||||
mockStore.brushSettings.hardness = 1
|
||||
mockStore.zoomRatio = 2
|
||||
|
||||
renderCursor()
|
||||
|
||||
const style = styleOf(getBrushEl())
|
||||
expect(style).toContain('width: 80px')
|
||||
expect(style).toContain('height: 80px')
|
||||
})
|
||||
|
||||
it('should grow effective size when hardness drops below 1', () => {
|
||||
mockStore.brushSettings.size = 100
|
||||
mockStore.brushSettings.hardness = 0
|
||||
mockStore.zoomRatio = 1
|
||||
|
||||
renderCursor()
|
||||
|
||||
// effective = 100 * (1 + 1.0 * 0.5) = 150 → diameter = 300
|
||||
expect(styleOf(getBrushEl())).toContain('width: 300px')
|
||||
})
|
||||
|
||||
it('should use 50% borderRadius for Arc brush', () => {
|
||||
mockStore.brushSettings.type = BrushShape.Arc
|
||||
renderCursor()
|
||||
expect(styleOf(getBrushEl())).toContain('border-radius: 50%')
|
||||
})
|
||||
|
||||
it('should use 0% borderRadius for Rect brush', () => {
|
||||
mockStore.brushSettings.type = BrushShape.Rect
|
||||
renderCursor()
|
||||
expect(styleOf(getBrushEl())).toContain('border-radius: 0%')
|
||||
})
|
||||
})
|
||||
|
||||
describe('position', () => {
|
||||
it('should anchor to cursorPoint plus panOffset minus radius (no container)', () => {
|
||||
mockStore.cursorPoint = { x: 200, y: 300 }
|
||||
mockStore.panOffset = { x: 50, y: 25 }
|
||||
mockStore.brushSettings.size = 20
|
||||
mockStore.brushSettings.hardness = 1
|
||||
mockStore.zoomRatio = 1
|
||||
|
||||
renderCursor()
|
||||
|
||||
// radius = effective(20,1) * 1 = 20
|
||||
// left = 200 + 50 - 20 = 230
|
||||
// top = 300 + 25 - 20 = 305
|
||||
const style = styleOf(getBrushEl())
|
||||
expect(style).toContain('left: 230px')
|
||||
expect(style).toContain('top: 305px')
|
||||
})
|
||||
|
||||
it('should subtract container offset when containerRef is provided', () => {
|
||||
mockStore.cursorPoint = { x: 200, y: 300 }
|
||||
mockStore.panOffset = { x: 0, y: 0 }
|
||||
mockStore.brushSettings.size = 20
|
||||
mockStore.brushSettings.hardness = 1
|
||||
mockStore.zoomRatio = 1
|
||||
|
||||
const container = document.createElement('div')
|
||||
vi.spyOn(container, 'getBoundingClientRect').mockReturnValue({
|
||||
left: 30,
|
||||
top: 60,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({})
|
||||
} as DOMRect)
|
||||
|
||||
renderCursor(container)
|
||||
|
||||
// left = 200 + 0 - 20 - 30 = 150; top = 300 + 0 - 20 - 60 = 220
|
||||
const style = styleOf(getBrushEl())
|
||||
expect(style).toContain('left: 150px')
|
||||
expect(style).toContain('top: 220px')
|
||||
})
|
||||
})
|
||||
|
||||
describe('gradient preview', () => {
|
||||
it('should be hidden by default', () => {
|
||||
mockStore.brushPreviewGradientVisible = false
|
||||
renderCursor()
|
||||
expect(styleOf(getGradientEl())).toContain('display: none')
|
||||
})
|
||||
|
||||
it('should be visible when brushPreviewGradientVisible is true', () => {
|
||||
mockStore.brushPreviewGradientVisible = true
|
||||
renderCursor()
|
||||
expect(styleOf(getGradientEl())).toContain('display: block')
|
||||
})
|
||||
|
||||
it('should use a flat fill at hardness=1', () => {
|
||||
mockStore.brushPreviewGradientVisible = true
|
||||
mockStore.brushSettings.hardness = 1
|
||||
mockStore.brushSettings.size = 20
|
||||
renderCursor()
|
||||
|
||||
// hard brush: getEffectiveHardness = (20*1)/20 = 1 → flat color
|
||||
const style = styleOf(getGradientEl())
|
||||
expect(style).toContain('rgba(255, 0, 0, 0.5)')
|
||||
expect(style).not.toContain('radial-gradient')
|
||||
})
|
||||
|
||||
// The radial-gradient (hardness < 1) branch uses a multi-line template
|
||||
// literal as the background value; happy-dom's CSS parser drops the
|
||||
// declaration entirely, so we can't assert on the rendered style. The
|
||||
// underlying math (getEffectiveBrushSize / getEffectiveHardness) is
|
||||
// covered by brushUtils.test.ts.
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
id="maskEditor_brush"
|
||||
data-testid="brush-cursor"
|
||||
:style="{
|
||||
position: 'absolute',
|
||||
opacity: brushOpacity,
|
||||
@@ -16,7 +15,6 @@
|
||||
>
|
||||
<div
|
||||
id="maskEditor_brushPreviewGradient"
|
||||
data-testid="brush-cursor-gradient"
|
||||
:style="{
|
||||
display: gradientVisible ? 'block' : 'none',
|
||||
background: gradientBackground
|
||||
|
||||
@@ -1,230 +0,0 @@
|
||||
/* eslint-disable testing-library/no-container, testing-library/no-node-access -- shape buttons are unlabeled divs and number inputs have no aria labels */
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { reactive } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import BrushSettingsPanel from '@/components/maskeditor/BrushSettingsPanel.vue'
|
||||
import { BrushShape } from '@/extensions/core/maskeditor/types'
|
||||
|
||||
const initialMock = () => ({
|
||||
brushSettings: reactive({
|
||||
type: BrushShape.Arc,
|
||||
size: 10,
|
||||
opacity: 0.7,
|
||||
hardness: 1,
|
||||
stepSize: 5
|
||||
}),
|
||||
rgbColor: '#FF0000',
|
||||
colorInput: null as HTMLInputElement | null,
|
||||
setBrushSize: vi.fn(),
|
||||
setBrushOpacity: vi.fn(),
|
||||
setBrushHardness: vi.fn(),
|
||||
setBrushStepSize: vi.fn(),
|
||||
resetBrushToDefault: vi.fn()
|
||||
})
|
||||
|
||||
let mockStore: ReturnType<typeof initialMock>
|
||||
|
||||
vi.mock('@/stores/maskEditorStore', () => ({
|
||||
useMaskEditorStore: () => mockStore
|
||||
}))
|
||||
|
||||
vi.mock('@/components/maskeditor/controls/SliderControl.vue', () => ({
|
||||
default: {
|
||||
name: 'SliderControlStub',
|
||||
props: ['label', 'min', 'max', 'step', 'modelValue'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `<button data-slider="true" @click="$emit('update:modelValue', 0.5)">{{ modelValue }}</button>`
|
||||
}
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
maskEditor: {
|
||||
brushSettings: 'Brush Settings',
|
||||
brushShape: 'Brush Shape',
|
||||
colorSelector: 'Color Selector',
|
||||
thickness: 'Thickness',
|
||||
opacity: 'Opacity',
|
||||
hardness: 'Hardness',
|
||||
stepSize: 'Step Size',
|
||||
resetToDefault: 'Reset to Default'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const renderPanel = () =>
|
||||
render(BrushSettingsPanel, { global: { plugins: [i18n] } })
|
||||
|
||||
const setNumberInput = (input: HTMLInputElement, value: string): void => {
|
||||
input.value = value
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
}
|
||||
|
||||
describe('BrushSettingsPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockStore = initialMock()
|
||||
})
|
||||
|
||||
describe('brush shape buttons', () => {
|
||||
it('should set brushSettings.type to Arc when arc button clicked', async () => {
|
||||
mockStore.brushSettings.type = BrushShape.Rect
|
||||
const { container } = renderPanel()
|
||||
const user = userEvent.setup()
|
||||
|
||||
const arcEl = container.querySelector(
|
||||
'.maskEditor_sidePanelBrushShapeCircle'
|
||||
)
|
||||
await user.click(arcEl as Element)
|
||||
|
||||
expect(mockStore.brushSettings.type).toBe(BrushShape.Arc)
|
||||
})
|
||||
|
||||
it('should set brushSettings.type to Rect when rect button clicked', async () => {
|
||||
const { container } = renderPanel()
|
||||
const user = userEvent.setup()
|
||||
|
||||
const rectEl = container.querySelector(
|
||||
'.maskEditor_sidePanelBrushShapeSquare'
|
||||
)
|
||||
await user.click(rectEl as Element)
|
||||
|
||||
expect(mockStore.brushSettings.type).toBe(BrushShape.Rect)
|
||||
})
|
||||
})
|
||||
|
||||
describe('reset button', () => {
|
||||
it('should call resetBrushToDefault when clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderPanel()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Reset to Default' }))
|
||||
|
||||
expect(mockStore.resetBrushToDefault).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('numeric inputs', () => {
|
||||
it('should call setBrushSize when size number input changes', () => {
|
||||
const { container } = renderPanel()
|
||||
const sizeInput = container.querySelectorAll(
|
||||
'input[type="number"]'
|
||||
)[0] as HTMLInputElement
|
||||
|
||||
setNumberInput(sizeInput, '50')
|
||||
|
||||
expect(mockStore.setBrushSize).toHaveBeenCalledWith(50)
|
||||
})
|
||||
|
||||
it('should call setBrushOpacity when opacity number input changes', () => {
|
||||
const { container } = renderPanel()
|
||||
const opacityInput = container.querySelectorAll(
|
||||
'input[type="number"]'
|
||||
)[1] as HTMLInputElement
|
||||
|
||||
setNumberInput(opacityInput, '0.4')
|
||||
|
||||
expect(mockStore.setBrushOpacity).toHaveBeenCalledWith(0.4)
|
||||
})
|
||||
|
||||
it('should call setBrushHardness when hardness number input changes', () => {
|
||||
const { container } = renderPanel()
|
||||
const hardnessInput = container.querySelectorAll(
|
||||
'input[type="number"]'
|
||||
)[2] as HTMLInputElement
|
||||
|
||||
setNumberInput(hardnessInput, '0.6')
|
||||
|
||||
expect(mockStore.setBrushHardness).toHaveBeenCalledWith(0.6)
|
||||
})
|
||||
|
||||
it('should call setBrushStepSize when step number input changes', () => {
|
||||
const { container } = renderPanel()
|
||||
const stepInput = container.querySelectorAll(
|
||||
'input[type="number"]'
|
||||
)[3] as HTMLInputElement
|
||||
|
||||
setNumberInput(stepInput, '20')
|
||||
|
||||
expect(mockStore.setBrushStepSize).toHaveBeenCalledWith(20)
|
||||
})
|
||||
})
|
||||
|
||||
describe('size slider (logarithmic)', () => {
|
||||
it('should call setBrushSize with Math.round(Math.pow(250, value))', () => {
|
||||
const { container } = renderPanel()
|
||||
const sizeSlider = container.querySelectorAll(
|
||||
'[data-slider="true"]'
|
||||
)[0] as HTMLElement
|
||||
|
||||
sizeSlider.click()
|
||||
// value = 0.5 → Math.round(Math.pow(250, 0.5)) = 16
|
||||
expect(mockStore.setBrushSize).toHaveBeenCalledWith(16)
|
||||
})
|
||||
|
||||
it('should map size 250 to slider value 1', () => {
|
||||
mockStore.brushSettings.size = 250
|
||||
const { container } = renderPanel()
|
||||
const sizeSlider = container.querySelectorAll(
|
||||
'[data-slider="true"]'
|
||||
)[0] as HTMLElement
|
||||
|
||||
// Math.log(250) / Math.log(250) = 1
|
||||
expect(sizeSlider.textContent).toContain('1')
|
||||
})
|
||||
|
||||
it('should return cached raw slider value when size matches the mapping', async () => {
|
||||
mockStore.setBrushSize.mockImplementation((size: number) => {
|
||||
mockStore.brushSettings.size = size
|
||||
})
|
||||
const { container } = renderPanel()
|
||||
const sizeSlider = container.querySelectorAll(
|
||||
'[data-slider="true"]'
|
||||
)[0] as HTMLElement
|
||||
|
||||
// Click sets rawSliderValue=0.5 → setBrushSize(16) → size=16
|
||||
// → next getter run sees cached match → returns 0.5
|
||||
sizeSlider.click()
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
expect(sizeSlider.textContent).toContain('0.5')
|
||||
})
|
||||
})
|
||||
|
||||
describe('color input', () => {
|
||||
it('should v-model rgbColor on the color input', () => {
|
||||
const { container } = renderPanel()
|
||||
const colorInput = container.querySelector(
|
||||
'input[type="color"]'
|
||||
) as HTMLInputElement
|
||||
|
||||
colorInput.value = '#00ff00'
|
||||
colorInput.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
|
||||
expect(mockStore.rgbColor).toBe('#00ff00')
|
||||
})
|
||||
|
||||
it('should expose color input ref to the store on mount', () => {
|
||||
const { container } = renderPanel()
|
||||
const colorInput = container.querySelector('input[type="color"]')
|
||||
|
||||
expect(mockStore.colorInput).toBe(colorInput)
|
||||
})
|
||||
|
||||
it('should clear store.colorInput on unmount', () => {
|
||||
const { unmount } = renderPanel()
|
||||
expect(mockStore.colorInput).not.toBeNull()
|
||||
|
||||
unmount()
|
||||
|
||||
expect(mockStore.colorInput).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,168 +0,0 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import ColorSelectSettingsPanel from '@/components/maskeditor/ColorSelectSettingsPanel.vue'
|
||||
import { ColorComparisonMethod } from '@/extensions/core/maskeditor/types'
|
||||
|
||||
const mockStore = vi.hoisted(() => ({
|
||||
colorSelectTolerance: 20,
|
||||
selectionOpacity: 100,
|
||||
colorSelectLivePreview: false,
|
||||
applyWholeImage: false,
|
||||
colorComparisonMethod: 'simple' as ColorComparisonMethod,
|
||||
maskBoundary: false,
|
||||
maskTolerance: 0,
|
||||
setColorSelectTolerance: vi.fn(),
|
||||
setSelectionOpacity: vi.fn(),
|
||||
setMaskTolerance: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/maskEditorStore', () => ({
|
||||
useMaskEditorStore: () => mockStore
|
||||
}))
|
||||
|
||||
vi.mock('@/components/maskeditor/controls/SliderControl.vue', () => ({
|
||||
default: {
|
||||
name: 'SliderControlStub',
|
||||
props: ['label', 'min', 'max', 'step', 'modelValue'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `<button data-control="slider" :aria-label="label" @click="$emit('update:modelValue', 99)">{{ modelValue }}</button>`
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/maskeditor/controls/ToggleControl.vue', () => ({
|
||||
default: {
|
||||
name: 'ToggleControlStub',
|
||||
props: ['label', 'modelValue'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `<button data-control="toggle" :aria-label="label" @click="$emit('update:modelValue', !modelValue)">{{ modelValue }}</button>`
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/maskeditor/controls/DropdownControl.vue', () => ({
|
||||
default: {
|
||||
name: 'DropdownControlStub',
|
||||
props: ['label', 'options', 'modelValue'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `<button data-control="dropdown" :aria-label="label" @click="$emit('update:modelValue', 'lab')">{{ modelValue }}</button>`
|
||||
}
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
maskEditor: {
|
||||
colorSelectSettings: 'Color Select Settings',
|
||||
tolerance: 'Tolerance',
|
||||
selectionOpacity: 'Selection Opacity',
|
||||
livePreview: 'Live Preview',
|
||||
applyToWholeImage: 'Apply to Whole Image',
|
||||
method: 'Method',
|
||||
stopAtMask: 'Stop at mask',
|
||||
maskTolerance: 'Mask Tolerance'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const renderPanel = () =>
|
||||
render(ColorSelectSettingsPanel, { global: { plugins: [i18n] } })
|
||||
|
||||
describe('ColorSelectSettingsPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockStore.colorSelectTolerance = 20
|
||||
mockStore.selectionOpacity = 100
|
||||
mockStore.colorSelectLivePreview = false
|
||||
mockStore.applyWholeImage = false
|
||||
mockStore.colorComparisonMethod = ColorComparisonMethod.Simple
|
||||
mockStore.maskBoundary = false
|
||||
mockStore.maskTolerance = 0
|
||||
})
|
||||
|
||||
it('should call setColorSelectTolerance when tolerance slider emits', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderPanel()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Tolerance' }))
|
||||
|
||||
expect(mockStore.setColorSelectTolerance).toHaveBeenCalledWith(99)
|
||||
})
|
||||
|
||||
it('should call setSelectionOpacity when selection opacity slider emits', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderPanel()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Selection Opacity' }))
|
||||
|
||||
expect(mockStore.setSelectionOpacity).toHaveBeenCalledWith(99)
|
||||
})
|
||||
|
||||
it('should toggle colorSelectLivePreview when live preview toggle emits', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockStore.colorSelectLivePreview = false
|
||||
renderPanel()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Live Preview' }))
|
||||
|
||||
expect(mockStore.colorSelectLivePreview).toBe(true)
|
||||
})
|
||||
|
||||
it('should toggle applyWholeImage when whole-image toggle emits', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockStore.applyWholeImage = false
|
||||
renderPanel()
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Apply to Whole Image' })
|
||||
)
|
||||
|
||||
expect(mockStore.applyWholeImage).toBe(true)
|
||||
})
|
||||
|
||||
it('should set comparison method when dropdown emits', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderPanel()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Method' }))
|
||||
|
||||
expect(mockStore.colorComparisonMethod).toBe(ColorComparisonMethod.LAB)
|
||||
})
|
||||
|
||||
it('should toggle maskBoundary when stop-at-mask toggle emits', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockStore.maskBoundary = false
|
||||
renderPanel()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Stop at mask' }))
|
||||
|
||||
expect(mockStore.maskBoundary).toBe(true)
|
||||
})
|
||||
|
||||
it('should call setMaskTolerance when mask tolerance slider emits', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderPanel()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Mask Tolerance' }))
|
||||
|
||||
expect(mockStore.setMaskTolerance).toHaveBeenCalledWith(99)
|
||||
})
|
||||
|
||||
it('should reflect store values on the controls', () => {
|
||||
mockStore.colorSelectTolerance = 77
|
||||
mockStore.colorComparisonMethod = ColorComparisonMethod.HSL
|
||||
|
||||
renderPanel()
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Tolerance' }).textContent
|
||||
).toContain('77')
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Method' }).textContent
|
||||
).toContain('hsl')
|
||||
})
|
||||
})
|
||||
@@ -1,281 +0,0 @@
|
||||
/* eslint-disable testing-library/no-container, testing-library/no-node-access -- layer rows have unlabeled checkboxes and the blend-mode select has no role-friendly label */
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { reactive } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { useToolManager } from '@/composables/maskeditor/useToolManager'
|
||||
import ImageLayerSettingsPanel from '@/components/maskeditor/ImageLayerSettingsPanel.vue'
|
||||
import { MaskBlendMode, Tools } from '@/extensions/core/maskeditor/types'
|
||||
|
||||
type ToolManager = ReturnType<typeof useToolManager>
|
||||
|
||||
const initialMock = () =>
|
||||
reactive({
|
||||
maskOpacity: 0.8,
|
||||
maskBlendMode: MaskBlendMode.Black,
|
||||
activeLayer: 'mask' as 'mask' | 'rgb',
|
||||
currentTool: Tools.MaskPen,
|
||||
image: { src: 'https://example.com/base.png' } as { src: string } | null,
|
||||
maskCanvas: null as HTMLCanvasElement | null,
|
||||
rgbCanvas: null as HTMLCanvasElement | null,
|
||||
imgCanvas: null as HTMLCanvasElement | null,
|
||||
setMaskOpacity: vi.fn()
|
||||
})
|
||||
|
||||
let mockStore: ReturnType<typeof initialMock>
|
||||
const mockUpdateMaskColor = vi.fn().mockResolvedValue(undefined)
|
||||
const mockSetActiveLayer = vi.fn()
|
||||
|
||||
vi.mock('@/stores/maskEditorStore', () => ({
|
||||
useMaskEditorStore: () => mockStore
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/maskeditor/useCanvasManager', () => ({
|
||||
useCanvasManager: () => ({ updateMaskColor: mockUpdateMaskColor })
|
||||
}))
|
||||
|
||||
vi.mock('@/components/maskeditor/controls/SliderControl.vue', () => ({
|
||||
default: {
|
||||
name: 'SliderControlStub',
|
||||
props: ['label', 'min', 'max', 'step', 'modelValue'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `<button data-slider="true" @click="$emit('update:modelValue', 0.3)">{{ modelValue }}</button>`
|
||||
}
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
maskEditor: {
|
||||
layers: 'Layers',
|
||||
maskOpacity: 'Mask Opacity',
|
||||
maskBlendingOptions: 'Mask Blending Options',
|
||||
black: 'Black',
|
||||
white: 'White',
|
||||
negative: 'Negative',
|
||||
maskLayer: 'Mask Layer',
|
||||
paintLayer: 'Paint Layer',
|
||||
baseImageLayer: 'Base Image Layer',
|
||||
activateLayer: 'Activate Layer',
|
||||
baseLayerPreview: 'Base layer preview'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const renderPanel = (props?: Record<string, unknown>) =>
|
||||
render(ImageLayerSettingsPanel, {
|
||||
global: { plugins: [i18n] },
|
||||
props
|
||||
})
|
||||
|
||||
const makeCanvas = (): HTMLCanvasElement => document.createElement('canvas')
|
||||
|
||||
describe('ImageLayerSettingsPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockStore = initialMock()
|
||||
})
|
||||
|
||||
describe('mask opacity slider', () => {
|
||||
it('should call setMaskOpacity and update mask canvas opacity', async () => {
|
||||
const user = userEvent.setup()
|
||||
const canvas = makeCanvas()
|
||||
mockStore.maskCanvas = canvas
|
||||
const { container } = renderPanel()
|
||||
|
||||
await user.click(
|
||||
container.querySelector('[data-slider="true"]') as HTMLElement
|
||||
)
|
||||
|
||||
expect(mockStore.setMaskOpacity).toHaveBeenCalledWith(0.3)
|
||||
expect(canvas.style.opacity).toBe('0.3')
|
||||
})
|
||||
|
||||
it('should leave canvas alone when no maskCanvas is set', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { container } = renderPanel()
|
||||
|
||||
await expect(
|
||||
user.click(
|
||||
container.querySelector('[data-slider="true"]') as HTMLElement
|
||||
)
|
||||
).resolves.not.toThrow()
|
||||
|
||||
expect(mockStore.setMaskOpacity).toHaveBeenCalledWith(0.3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('blend mode select', () => {
|
||||
it.each([
|
||||
['black', MaskBlendMode.Black],
|
||||
['white', MaskBlendMode.White],
|
||||
['negative', MaskBlendMode.Negative],
|
||||
['unknown-fallback', MaskBlendMode.Black]
|
||||
] as const)('should map %s to MaskBlendMode.%s', async (raw, expected) => {
|
||||
const { container } = renderPanel()
|
||||
const select = container.querySelector('select') as HTMLSelectElement
|
||||
|
||||
Object.defineProperty(select, 'value', {
|
||||
value: raw,
|
||||
configurable: true
|
||||
})
|
||||
select.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
|
||||
expect(mockStore.maskBlendMode).toBe(expected)
|
||||
expect(mockUpdateMaskColor).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('layer visibility checkboxes', () => {
|
||||
it('should toggle mask canvas opacity to maskOpacity when checked, 0 when unchecked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const canvas = makeCanvas()
|
||||
mockStore.maskCanvas = canvas
|
||||
mockStore.maskOpacity = 0.5
|
||||
|
||||
const { container } = renderPanel()
|
||||
const checkbox = container.querySelectorAll(
|
||||
'input[type="checkbox"]'
|
||||
)[0] as HTMLInputElement
|
||||
|
||||
await user.click(checkbox)
|
||||
expect(canvas.style.opacity).toBe('0')
|
||||
|
||||
await user.click(checkbox)
|
||||
expect(canvas.style.opacity).toBe('0.5')
|
||||
})
|
||||
|
||||
it('should toggle paint (rgb) canvas opacity between 0 and 1', async () => {
|
||||
const user = userEvent.setup()
|
||||
const canvas = makeCanvas()
|
||||
mockStore.rgbCanvas = canvas
|
||||
|
||||
const { container } = renderPanel()
|
||||
const checkbox = container.querySelectorAll(
|
||||
'input[type="checkbox"]'
|
||||
)[1] as HTMLInputElement
|
||||
|
||||
await user.click(checkbox)
|
||||
expect(canvas.style.opacity).toBe('0')
|
||||
|
||||
await user.click(checkbox)
|
||||
expect(canvas.style.opacity).toBe('1')
|
||||
})
|
||||
|
||||
it('should toggle base image canvas opacity between 0 and 1', async () => {
|
||||
const user = userEvent.setup()
|
||||
const canvas = makeCanvas()
|
||||
mockStore.imgCanvas = canvas
|
||||
|
||||
const { container } = renderPanel()
|
||||
const checkbox = container.querySelectorAll(
|
||||
'input[type="checkbox"]'
|
||||
)[2] as HTMLInputElement
|
||||
|
||||
await user.click(checkbox)
|
||||
expect(canvas.style.opacity).toBe('0')
|
||||
})
|
||||
|
||||
it('should not throw when toggling visibility for missing canvases', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { container } = renderPanel()
|
||||
const checkboxes = container.querySelectorAll('input[type="checkbox"]')
|
||||
|
||||
for (const cb of checkboxes) {
|
||||
await expect(user.click(cb as HTMLInputElement)).resolves.not.toThrow()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('activate layer buttons', () => {
|
||||
it('should forward the layer to toolManager.setActiveLayer when clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockStore.activeLayer = 'rgb'
|
||||
|
||||
renderPanel({
|
||||
toolManager: {
|
||||
setActiveLayer: mockSetActiveLayer
|
||||
} as unknown as ToolManager
|
||||
})
|
||||
|
||||
const [maskBtn] = screen.getAllByRole('button', {
|
||||
name: 'Activate Layer'
|
||||
})
|
||||
await user.click(maskBtn)
|
||||
|
||||
expect(mockSetActiveLayer).toHaveBeenCalledWith('mask')
|
||||
})
|
||||
|
||||
it('should not throw when toolManager prop is omitted', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockStore.activeLayer = 'rgb'
|
||||
|
||||
renderPanel()
|
||||
const [maskBtn] = screen.getAllByRole('button', {
|
||||
name: 'Activate Layer'
|
||||
})
|
||||
|
||||
await expect(user.click(maskBtn)).resolves.not.toThrow()
|
||||
})
|
||||
|
||||
it('should mark the active-layer button disabled', () => {
|
||||
mockStore.activeLayer = 'mask'
|
||||
|
||||
renderPanel()
|
||||
const [maskBtn] = screen.getAllByRole('button', {
|
||||
name: 'Activate Layer'
|
||||
})
|
||||
|
||||
expect(maskBtn.hasAttribute('disabled')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('paint layer activate visibility', () => {
|
||||
const styleOf = (el: Element): string => el.getAttribute('style') ?? ''
|
||||
|
||||
it('should hide paint activate button when current tool is not Eraser', () => {
|
||||
mockStore.currentTool = Tools.MaskPen
|
||||
const { container } = renderPanel()
|
||||
const buttons = Array.from(container.querySelectorAll('button'))
|
||||
const paintBtn = buttons.find((b) => styleOf(b).includes('display:'))
|
||||
|
||||
expect(paintBtn).toBeDefined()
|
||||
expect(styleOf(paintBtn as Element)).toContain('display: none')
|
||||
})
|
||||
|
||||
it('should show paint activate button when current tool is Eraser', () => {
|
||||
mockStore.currentTool = Tools.Eraser
|
||||
const { container } = renderPanel()
|
||||
const buttons = Array.from(container.querySelectorAll('button'))
|
||||
const paintBtn = buttons.find((b) => styleOf(b).includes('display:'))
|
||||
|
||||
expect(paintBtn).toBeDefined()
|
||||
expect(styleOf(paintBtn as Element)).toContain('display: block')
|
||||
})
|
||||
})
|
||||
|
||||
describe('base image preview', () => {
|
||||
it('should render base image src from store', () => {
|
||||
mockStore.image = { src: 'https://example.com/img.png' }
|
||||
renderPanel()
|
||||
const img = screen.getByAltText('Base layer preview')
|
||||
|
||||
expect((img as HTMLImageElement).src).toBe('https://example.com/img.png')
|
||||
})
|
||||
|
||||
it('should render empty src when no image', () => {
|
||||
mockStore.image = null
|
||||
renderPanel()
|
||||
const img = screen.getByAltText('Base layer preview')
|
||||
|
||||
expect(img.getAttribute('src')).toBe('')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,354 +0,0 @@
|
||||
import { render, screen, waitFor } from '@testing-library/vue'
|
||||
import { reactive } from 'vue'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import MaskEditorContent from '@/components/maskeditor/MaskEditorContent.vue'
|
||||
|
||||
const mockKeyboard = vi.hoisted(() => ({
|
||||
addListeners: vi.fn(),
|
||||
removeListeners: vi.fn()
|
||||
}))
|
||||
|
||||
const mockPanZoom = vi.hoisted(() => ({
|
||||
initializeCanvasPanZoom: vi.fn().mockResolvedValue(undefined),
|
||||
invalidatePanZoom: vi.fn().mockResolvedValue(undefined)
|
||||
}))
|
||||
|
||||
const mockBrushDrawing = vi.hoisted(() => ({
|
||||
initGPUResources: vi.fn().mockResolvedValue(undefined),
|
||||
initPreviewCanvas: vi.fn(),
|
||||
saveBrushSettings: vi.fn()
|
||||
}))
|
||||
|
||||
const mockToolManager = vi.hoisted(() => ({
|
||||
brushDrawing: mockBrushDrawing
|
||||
}))
|
||||
|
||||
const mockImageLoader = vi.hoisted(() => ({
|
||||
loadImages: vi.fn().mockResolvedValue({ width: 100, height: 100 })
|
||||
}))
|
||||
|
||||
const mockMaskEditorLoader = vi.hoisted(() => ({
|
||||
loadFromNode: vi.fn().mockResolvedValue(undefined)
|
||||
}))
|
||||
|
||||
const mockCanvasHistory = vi.hoisted(() => ({
|
||||
saveInitialState: vi.fn(),
|
||||
clearStates: vi.fn()
|
||||
}))
|
||||
|
||||
const initialMockStore = () =>
|
||||
reactive({
|
||||
activeLayer: 'mask' as 'mask' | 'rgb',
|
||||
maskCanvas: null as HTMLCanvasElement | null,
|
||||
rgbCanvas: null as HTMLCanvasElement | null,
|
||||
imgCanvas: null as HTMLCanvasElement | null,
|
||||
canvasContainer: null as HTMLElement | null,
|
||||
canvasBackground: null as HTMLElement | null,
|
||||
canvasHistory: mockCanvasHistory,
|
||||
resetState: vi.fn()
|
||||
})
|
||||
|
||||
let mockStore: ReturnType<typeof initialMockStore>
|
||||
|
||||
const mockDataStore = vi.hoisted(() => ({
|
||||
reset: vi.fn()
|
||||
}))
|
||||
|
||||
const mockDialogStore = vi.hoisted(() => ({
|
||||
closeDialog: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/maskeditor/useKeyboard', () => ({
|
||||
useKeyboard: () => mockKeyboard
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/maskeditor/usePanAndZoom', () => ({
|
||||
usePanAndZoom: () => mockPanZoom
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/maskeditor/useToolManager', () => ({
|
||||
useToolManager: () => mockToolManager
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/maskeditor/useImageLoader', () => ({
|
||||
useImageLoader: () => mockImageLoader
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/maskeditor/useMaskEditorLoader', () => ({
|
||||
useMaskEditorLoader: () => mockMaskEditorLoader
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/maskEditorStore', () => ({
|
||||
useMaskEditorStore: () => mockStore
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/maskEditorDataStore', () => ({
|
||||
useMaskEditorDataStore: () => mockDataStore
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: () => mockDialogStore
|
||||
}))
|
||||
|
||||
vi.mock('@/components/common/LoadingOverlay.vue', () => ({
|
||||
default: {
|
||||
name: 'LoadingOverlayStub',
|
||||
props: ['loading', 'size'],
|
||||
template: `<div data-testid="loading-overlay" :data-loading="loading" />`
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/maskeditor/ToolPanel.vue', () => ({
|
||||
default: {
|
||||
name: 'ToolPanelStub',
|
||||
props: ['toolManager'],
|
||||
template: '<div data-testid="tool-panel-stub" />'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/maskeditor/PointerZone.vue', () => ({
|
||||
default: {
|
||||
name: 'PointerZoneStub',
|
||||
props: ['toolManager', 'panZoom'],
|
||||
template: '<div data-testid="pointer-zone-stub" />'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/maskeditor/SidePanel.vue', () => ({
|
||||
default: {
|
||||
name: 'SidePanelStub',
|
||||
props: ['toolManager'],
|
||||
template: '<div data-testid="side-panel-stub" />'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/maskeditor/BrushCursor.vue', () => ({
|
||||
default: {
|
||||
name: 'BrushCursorStub',
|
||||
props: ['containerRef'],
|
||||
template: '<div data-testid="brush-cursor-stub" />'
|
||||
}
|
||||
}))
|
||||
|
||||
const observeSpy = vi.fn()
|
||||
const disconnectSpy = vi.fn()
|
||||
let lastResizeCallback: ResizeObserverCallback | null = null
|
||||
|
||||
class MockResizeObserver {
|
||||
observe = observeSpy
|
||||
disconnect = disconnectSpy
|
||||
unobserve = vi.fn()
|
||||
constructor(cb: ResizeObserverCallback) {
|
||||
lastResizeCallback = cb
|
||||
}
|
||||
}
|
||||
|
||||
// `node` only flows into mocked `loader.loadFromNode`, so a typed sentinel
|
||||
// with a stable identity is enough — we never read its fields.
|
||||
const fakeNode = { id: 1, title: 'test-node' } as unknown as LGraphNode
|
||||
|
||||
const renderContent = () =>
|
||||
render(MaskEditorContent, { props: { node: fakeNode } })
|
||||
|
||||
let originalResizeObserver: typeof ResizeObserver | undefined
|
||||
|
||||
describe('MaskEditorContent', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockStore = initialMockStore()
|
||||
mockMaskEditorLoader.loadFromNode.mockResolvedValue(undefined)
|
||||
mockImageLoader.loadImages.mockResolvedValue({ width: 100, height: 100 })
|
||||
mockPanZoom.initializeCanvasPanZoom.mockResolvedValue(undefined)
|
||||
mockBrushDrawing.initGPUResources.mockResolvedValue(undefined)
|
||||
originalResizeObserver = globalThis.ResizeObserver
|
||||
globalThis.ResizeObserver =
|
||||
MockResizeObserver as unknown as typeof ResizeObserver
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.ResizeObserver =
|
||||
originalResizeObserver as unknown as typeof ResizeObserver
|
||||
})
|
||||
|
||||
describe('mount', () => {
|
||||
it('should add keyboard listeners on mount', () => {
|
||||
renderContent()
|
||||
expect(mockKeyboard.addListeners).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should observe the container with a ResizeObserver', async () => {
|
||||
renderContent()
|
||||
await waitFor(() => expect(observeSpy).toHaveBeenCalledTimes(1))
|
||||
})
|
||||
|
||||
it('should invalidate pan/zoom on resize', async () => {
|
||||
renderContent()
|
||||
await waitFor(() => expect(observeSpy).toHaveBeenCalled())
|
||||
mockPanZoom.invalidatePanZoom.mockClear()
|
||||
|
||||
lastResizeCallback?.([], {} as ResizeObserver)
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
|
||||
expect(mockPanZoom.invalidatePanZoom).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should assign canvas refs to the store before init', async () => {
|
||||
renderContent()
|
||||
await waitFor(() => expect(mockStore.maskCanvas).not.toBeNull())
|
||||
expect(mockStore.rgbCanvas).not.toBeNull()
|
||||
expect(mockStore.imgCanvas).not.toBeNull()
|
||||
expect(mockStore.canvasContainer).not.toBeNull()
|
||||
expect(mockStore.canvasBackground).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('init flow', () => {
|
||||
it('should run the init chain in the documented order', async () => {
|
||||
renderContent()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockBrushDrawing.initPreviewCanvas).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
const orderOf = (fn: { mock: { invocationCallOrder: number[] } }) =>
|
||||
fn.mock.invocationCallOrder[0]
|
||||
|
||||
expect(orderOf(mockMaskEditorLoader.loadFromNode)).toBeLessThan(
|
||||
orderOf(mockImageLoader.loadImages)
|
||||
)
|
||||
expect(orderOf(mockImageLoader.loadImages)).toBeLessThan(
|
||||
orderOf(mockPanZoom.initializeCanvasPanZoom)
|
||||
)
|
||||
expect(orderOf(mockPanZoom.initializeCanvasPanZoom)).toBeLessThan(
|
||||
orderOf(mockCanvasHistory.saveInitialState)
|
||||
)
|
||||
expect(orderOf(mockCanvasHistory.saveInitialState)).toBeLessThan(
|
||||
orderOf(mockBrushDrawing.initGPUResources)
|
||||
)
|
||||
expect(orderOf(mockBrushDrawing.initGPUResources)).toBeLessThan(
|
||||
orderOf(mockBrushDrawing.initPreviewCanvas)
|
||||
)
|
||||
expect(mockMaskEditorLoader.loadFromNode).toHaveBeenCalledWith(fakeNode)
|
||||
})
|
||||
|
||||
it('should reveal the child UI components after init succeeds', async () => {
|
||||
renderContent()
|
||||
|
||||
expect(await screen.findByTestId('tool-panel-stub')).toBeInTheDocument()
|
||||
expect(await screen.findByTestId('pointer-zone-stub')).toBeInTheDocument()
|
||||
expect(await screen.findByTestId('side-panel-stub')).toBeInTheDocument()
|
||||
expect(await screen.findByTestId('brush-cursor-stub')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should size the GPU preview canvas to match the mask canvas', async () => {
|
||||
// Force the mask canvas to non-default dimensions during init so the
|
||||
// assertion below proves the source actually copies width/height across
|
||||
// (default 300x150 on both would make the test tautological).
|
||||
mockBrushDrawing.initGPUResources.mockImplementationOnce(async () => {
|
||||
if (mockStore.maskCanvas) {
|
||||
mockStore.maskCanvas.width = 999
|
||||
mockStore.maskCanvas.height = 777
|
||||
}
|
||||
})
|
||||
renderContent()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockBrushDrawing.initPreviewCanvas).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
const previewCanvas = mockBrushDrawing.initPreviewCanvas.mock
|
||||
.calls[0][0] as HTMLCanvasElement
|
||||
expect(previewCanvas.width).toBe(999)
|
||||
expect(previewCanvas.height).toBe(777)
|
||||
})
|
||||
})
|
||||
|
||||
describe('init error', () => {
|
||||
it('should close the dialog and log when loader.loadFromNode rejects', async () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
mockMaskEditorLoader.loadFromNode.mockRejectedValueOnce(
|
||||
new Error('load failed')
|
||||
)
|
||||
|
||||
renderContent()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDialogStore.closeDialog).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
'[MaskEditorContent] Initialization failed:',
|
||||
expect.any(Error)
|
||||
)
|
||||
errorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should close the dialog and log when initializeCanvasPanZoom rejects', async () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
mockPanZoom.initializeCanvasPanZoom.mockRejectedValueOnce(
|
||||
new Error('panzoom failed')
|
||||
)
|
||||
|
||||
renderContent()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDialogStore.closeDialog).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
'[MaskEditorContent] Initialization failed:',
|
||||
expect.any(Error)
|
||||
)
|
||||
errorSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('drag handling', () => {
|
||||
it('should prevent default on dragstart with Ctrl held', () => {
|
||||
renderContent()
|
||||
const root = screen.getByTestId('mask-editor-root')
|
||||
|
||||
const event = new DragEvent('dragstart', {
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
// happy-dom doesn't propagate ctrlKey through the DragEvent constructor.
|
||||
Object.defineProperty(event, 'ctrlKey', { value: true })
|
||||
root.dispatchEvent(event)
|
||||
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
})
|
||||
|
||||
it('should not prevent default on plain dragstart without Ctrl', () => {
|
||||
renderContent()
|
||||
const root = screen.getByTestId('mask-editor-root')
|
||||
|
||||
const event = new DragEvent('dragstart', {
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
Object.defineProperty(event, 'ctrlKey', { value: false })
|
||||
root.dispatchEvent(event)
|
||||
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('unmount cleanup', () => {
|
||||
it('should run the full cleanup chain on unmount', async () => {
|
||||
const { unmount } = renderContent()
|
||||
await waitFor(() =>
|
||||
expect(mockBrushDrawing.initGPUResources).toHaveBeenCalled()
|
||||
)
|
||||
|
||||
unmount()
|
||||
|
||||
expect(mockBrushDrawing.saveBrushSettings).toHaveBeenCalledTimes(1)
|
||||
expect(mockKeyboard.removeListeners).toHaveBeenCalledTimes(1)
|
||||
expect(mockCanvasHistory.clearStates).toHaveBeenCalledTimes(1)
|
||||
expect(mockStore.resetState).toHaveBeenCalledTimes(1)
|
||||
expect(mockDataStore.reset).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
ref="containerRef"
|
||||
data-testid="mask-editor-root"
|
||||
class="maskEditor-dialog-root flex size-full flex-col"
|
||||
@contextmenu.prevent
|
||||
@dragstart="handleDragStart"
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import PaintBucketSettingsPanel from '@/components/maskeditor/PaintBucketSettingsPanel.vue'
|
||||
|
||||
const mockStore = vi.hoisted(() => ({
|
||||
paintBucketTolerance: 5,
|
||||
fillOpacity: 100,
|
||||
setPaintBucketTolerance: vi.fn(),
|
||||
setFillOpacity: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/maskEditorStore', () => ({
|
||||
useMaskEditorStore: () => mockStore
|
||||
}))
|
||||
|
||||
vi.mock('@/components/maskeditor/controls/SliderControl.vue', () => ({
|
||||
default: {
|
||||
name: 'SliderControlStub',
|
||||
props: ['label', 'min', 'max', 'step', 'modelValue'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `<button :aria-label="label" @click="$emit('update:modelValue', 42)">{{ modelValue }}</button>`
|
||||
}
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
maskEditor: {
|
||||
paintBucketSettings: 'Paint Bucket Settings',
|
||||
tolerance: 'Tolerance',
|
||||
fillOpacity: 'Fill Opacity'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const renderPanel = () =>
|
||||
render(PaintBucketSettingsPanel, { global: { plugins: [i18n] } })
|
||||
|
||||
describe('PaintBucketSettingsPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockStore.paintBucketTolerance = 5
|
||||
mockStore.fillOpacity = 100
|
||||
})
|
||||
|
||||
it('should bind tolerance slider to store value', () => {
|
||||
mockStore.paintBucketTolerance = 87
|
||||
renderPanel()
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Tolerance' }).textContent
|
||||
).toContain('87')
|
||||
})
|
||||
|
||||
it('should bind fill opacity slider to store value', () => {
|
||||
mockStore.fillOpacity = 33
|
||||
renderPanel()
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Fill Opacity' }).textContent
|
||||
).toContain('33')
|
||||
})
|
||||
|
||||
it('should call setPaintBucketTolerance when tolerance slider emits', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderPanel()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Tolerance' }))
|
||||
|
||||
expect(mockStore.setPaintBucketTolerance).toHaveBeenCalledWith(42)
|
||||
})
|
||||
|
||||
it('should call setFillOpacity when fill opacity slider emits', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderPanel()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Fill Opacity' }))
|
||||
|
||||
expect(mockStore.setFillOpacity).toHaveBeenCalledWith(42)
|
||||
})
|
||||
})
|
||||
@@ -1,184 +0,0 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { reactive, nextTick } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { usePanAndZoom } from '@/composables/maskeditor/usePanAndZoom'
|
||||
import type { useToolManager } from '@/composables/maskeditor/useToolManager'
|
||||
|
||||
import PointerZone from '@/components/maskeditor/PointerZone.vue'
|
||||
|
||||
type ToolManager = ReturnType<typeof useToolManager>
|
||||
type PanZoom = ReturnType<typeof usePanAndZoom>
|
||||
|
||||
const initialMock = () =>
|
||||
reactive({
|
||||
pointerZone: null as HTMLElement | null,
|
||||
isPanning: false,
|
||||
brushVisible: true
|
||||
})
|
||||
|
||||
let mockStore: ReturnType<typeof initialMock>
|
||||
|
||||
const mockToolManager = vi.hoisted(() => ({
|
||||
handlePointerDown: vi.fn().mockResolvedValue(undefined),
|
||||
handlePointerMove: vi.fn().mockResolvedValue(undefined),
|
||||
handlePointerUp: vi.fn().mockResolvedValue(undefined),
|
||||
updateCursor: vi.fn()
|
||||
}))
|
||||
|
||||
const mockPanZoom = vi.hoisted(() => ({
|
||||
handleTouchStart: vi.fn(),
|
||||
handleTouchMove: vi.fn().mockResolvedValue(undefined),
|
||||
handleTouchEnd: vi.fn(),
|
||||
zoom: vi.fn().mockResolvedValue(undefined),
|
||||
updateCursorPosition: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/maskEditorStore', () => ({
|
||||
useMaskEditorStore: () => mockStore
|
||||
}))
|
||||
|
||||
const renderZone = () =>
|
||||
render(PointerZone, {
|
||||
props: {
|
||||
toolManager: mockToolManager as unknown as ToolManager,
|
||||
panZoom: mockPanZoom as unknown as PanZoom
|
||||
}
|
||||
})
|
||||
|
||||
const getZone = (): HTMLDivElement =>
|
||||
screen.getByTestId('pointer-zone') as HTMLDivElement
|
||||
|
||||
describe('PointerZone', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockStore = initialMock()
|
||||
})
|
||||
|
||||
describe('mount', () => {
|
||||
it('should expose its root element to the store on mount', () => {
|
||||
renderZone()
|
||||
expect(mockStore.pointerZone).toBe(getZone())
|
||||
})
|
||||
})
|
||||
|
||||
describe('pointer event forwarding', () => {
|
||||
it.each([
|
||||
['pointerdown', 'handlePointerDown'],
|
||||
['pointermove', 'handlePointerMove'],
|
||||
['pointerup', 'handlePointerUp']
|
||||
] as const)(
|
||||
'should forward %s to toolManager.%s',
|
||||
async (eventName, handlerName) => {
|
||||
renderZone()
|
||||
const zone = getZone()
|
||||
|
||||
zone.dispatchEvent(new Event(eventName, { bubbles: true }))
|
||||
await nextTick()
|
||||
|
||||
expect(mockToolManager[handlerName]).toHaveBeenCalledTimes(1)
|
||||
}
|
||||
)
|
||||
|
||||
it('should hide brush and clear cursor on pointerleave', () => {
|
||||
renderZone()
|
||||
const zone = getZone()
|
||||
zone.style.cursor = 'crosshair'
|
||||
mockStore.brushVisible = true
|
||||
|
||||
zone.dispatchEvent(new Event('pointerleave', { bubbles: true }))
|
||||
|
||||
expect(mockStore.brushVisible).toBe(false)
|
||||
expect(zone.style.cursor).toBe('')
|
||||
})
|
||||
|
||||
it('should call toolManager.updateCursor on pointerenter', () => {
|
||||
renderZone()
|
||||
const zone = getZone()
|
||||
|
||||
zone.dispatchEvent(new Event('pointerenter', { bubbles: true }))
|
||||
|
||||
expect(mockToolManager.updateCursor).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('touch event forwarding', () => {
|
||||
it.each([
|
||||
['touchstart', 'handleTouchStart'],
|
||||
['touchmove', 'handleTouchMove'],
|
||||
['touchend', 'handleTouchEnd']
|
||||
] as const)(
|
||||
'should forward %s to panZoom.%s',
|
||||
async (eventName, handlerName) => {
|
||||
renderZone()
|
||||
const zone = getZone()
|
||||
|
||||
zone.dispatchEvent(new Event(eventName, { bubbles: true }))
|
||||
await nextTick()
|
||||
|
||||
expect(mockPanZoom[handlerName]).toHaveBeenCalledTimes(1)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('wheel handling', () => {
|
||||
it('should call panZoom.zoom and update cursor position with the wheel coords', async () => {
|
||||
renderZone()
|
||||
const zone = getZone()
|
||||
|
||||
const event = new WheelEvent('wheel', { bubbles: true, deltaY: -1 })
|
||||
// happy-dom doesn't propagate clientX/clientY through the WheelEvent
|
||||
// constructor, so set them directly on the event instance.
|
||||
Object.defineProperty(event, 'clientX', { value: 123 })
|
||||
Object.defineProperty(event, 'clientY', { value: 45 })
|
||||
zone.dispatchEvent(event)
|
||||
// Flush awaited zoom() then the follow-up updateCursorPosition call
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
expect(mockPanZoom.zoom).toHaveBeenCalledTimes(1)
|
||||
expect(mockPanZoom.updateCursorPosition).toHaveBeenCalledWith({
|
||||
x: 123,
|
||||
y: 45
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('isPanning watcher', () => {
|
||||
it('should set cursor to "grabbing" when panning starts', async () => {
|
||||
renderZone()
|
||||
const zone = getZone()
|
||||
|
||||
mockStore.isPanning = true
|
||||
await nextTick()
|
||||
|
||||
expect(zone.style.cursor).toBe('grabbing')
|
||||
})
|
||||
|
||||
it('should call toolManager.updateCursor when panning ends', async () => {
|
||||
renderZone()
|
||||
|
||||
mockStore.isPanning = true
|
||||
await nextTick()
|
||||
mockToolManager.updateCursor.mockClear()
|
||||
mockStore.isPanning = false
|
||||
await nextTick()
|
||||
|
||||
expect(mockToolManager.updateCursor).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('contextmenu', () => {
|
||||
it('should prevent default on contextmenu', () => {
|
||||
renderZone()
|
||||
const zone = getZone()
|
||||
|
||||
const event = new Event('contextmenu', {
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
zone.dispatchEvent(event)
|
||||
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
ref="pointerZoneRef"
|
||||
data-testid="pointer-zone"
|
||||
class="h-full w-[calc(100%-4rem-220px)]"
|
||||
@pointerdown="handlePointerDown"
|
||||
@pointermove="handlePointerMove"
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import { render } from '@testing-library/vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import SettingsPanelContainer from '@/components/maskeditor/SettingsPanelContainer.vue'
|
||||
import { Tools } from '@/extensions/core/maskeditor/types'
|
||||
|
||||
const mockStore = vi.hoisted(() => ({
|
||||
currentTool: 'pen' as Tools
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/maskEditorStore', () => ({
|
||||
useMaskEditorStore: () => mockStore
|
||||
}))
|
||||
|
||||
vi.mock('@/components/maskeditor/BrushSettingsPanel.vue', () => ({
|
||||
default: {
|
||||
name: 'BrushSettingsPanelStub',
|
||||
template: '<div>brush-panel</div>'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/maskeditor/ColorSelectSettingsPanel.vue', () => ({
|
||||
default: {
|
||||
name: 'ColorSelectSettingsPanelStub',
|
||||
template: '<div>color-panel</div>'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/maskeditor/PaintBucketSettingsPanel.vue', () => ({
|
||||
default: {
|
||||
name: 'PaintBucketSettingsPanelStub',
|
||||
template: '<div>bucket-panel</div>'
|
||||
}
|
||||
}))
|
||||
|
||||
describe('SettingsPanelContainer', () => {
|
||||
beforeEach(() => {
|
||||
mockStore.currentTool = Tools.MaskPen
|
||||
})
|
||||
|
||||
it('should render PaintBucketSettingsPanel when current tool is MaskBucket', () => {
|
||||
mockStore.currentTool = Tools.MaskBucket
|
||||
const { container } = render(SettingsPanelContainer)
|
||||
expect(container.textContent).toContain('bucket-panel')
|
||||
})
|
||||
|
||||
it('should render ColorSelectSettingsPanel when current tool is MaskColorFill', () => {
|
||||
mockStore.currentTool = Tools.MaskColorFill
|
||||
const { container } = render(SettingsPanelContainer)
|
||||
expect(container.textContent).toContain('color-panel')
|
||||
})
|
||||
|
||||
it('should render BrushSettingsPanel for any other tool', () => {
|
||||
mockStore.currentTool = Tools.MaskPen
|
||||
const { container } = render(SettingsPanelContainer)
|
||||
expect(container.textContent).toContain('brush-panel')
|
||||
})
|
||||
|
||||
it('should render BrushSettingsPanel for Eraser', () => {
|
||||
mockStore.currentTool = Tools.Eraser
|
||||
const { container } = render(SettingsPanelContainer)
|
||||
expect(container.textContent).toContain('brush-panel')
|
||||
})
|
||||
|
||||
it('should render BrushSettingsPanel for PaintPen', () => {
|
||||
mockStore.currentTool = Tools.PaintPen
|
||||
const { container } = render(SettingsPanelContainer)
|
||||
expect(container.textContent).toContain('brush-panel')
|
||||
})
|
||||
})
|
||||
@@ -1,49 +0,0 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { useToolManager } from '@/composables/maskeditor/useToolManager'
|
||||
|
||||
import SidePanel from '@/components/maskeditor/SidePanel.vue'
|
||||
|
||||
type ToolManager = ReturnType<typeof useToolManager>
|
||||
|
||||
vi.mock('@/components/maskeditor/SettingsPanelContainer.vue', () => ({
|
||||
default: {
|
||||
name: 'SettingsPanelContainerStub',
|
||||
template: '<div data-testid="settings-panel-stub">settings</div>'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/maskeditor/ImageLayerSettingsPanel.vue', () => ({
|
||||
default: {
|
||||
name: 'ImageLayerSettingsPanelStub',
|
||||
props: ['toolManager'],
|
||||
template:
|
||||
'<div data-testid="image-layer-stub">image-layer:{{ toolManager?.tag ?? "none" }}</div>'
|
||||
}
|
||||
}))
|
||||
|
||||
describe('SidePanel', () => {
|
||||
it('should render both child panels', () => {
|
||||
render(SidePanel)
|
||||
|
||||
expect(screen.getByTestId('settings-panel-stub')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('image-layer-stub')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should forward toolManager prop to ImageLayerSettingsPanel', () => {
|
||||
const toolManager = { tag: 'my-tool-manager' } as unknown as ToolManager
|
||||
|
||||
render(SidePanel, { props: { toolManager } })
|
||||
|
||||
expect(screen.getByTestId('image-layer-stub').textContent).toContain(
|
||||
'my-tool-manager'
|
||||
)
|
||||
})
|
||||
|
||||
it('should render with no toolManager passed through', () => {
|
||||
render(SidePanel)
|
||||
|
||||
expect(screen.getByTestId('image-layer-stub').textContent).toContain('none')
|
||||
})
|
||||
})
|
||||
@@ -1,155 +0,0 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { reactive } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { useToolManager } from '@/composables/maskeditor/useToolManager'
|
||||
|
||||
import ToolPanel from '@/components/maskeditor/ToolPanel.vue'
|
||||
import { Tools, allTools } from '@/extensions/core/maskeditor/types'
|
||||
|
||||
type ToolManager = ReturnType<typeof useToolManager>
|
||||
|
||||
vi.mock('@/extensions/core/maskeditor/constants', () => ({
|
||||
iconsHtml: {
|
||||
pen: '<svg data-testid="icon-pen" />',
|
||||
rgbPaint: '<svg data-testid="icon-rgbPaint" />',
|
||||
eraser: '<svg data-testid="icon-eraser" />',
|
||||
paintBucket: '<svg data-testid="icon-paintBucket" />',
|
||||
colorSelect: '<svg data-testid="icon-colorSelect" />'
|
||||
}
|
||||
}))
|
||||
|
||||
const initialMock = () =>
|
||||
reactive({
|
||||
currentTool: Tools.MaskPen as Tools,
|
||||
displayZoomRatio: 1,
|
||||
image: null as { width: number; height: number } | null,
|
||||
resetZoom: vi.fn()
|
||||
})
|
||||
|
||||
let mockStore: ReturnType<typeof initialMock>
|
||||
|
||||
vi.mock('@/stores/maskEditorStore', () => ({
|
||||
useMaskEditorStore: () => mockStore
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
maskEditor: {
|
||||
clickToResetZoom: 'Click to reset zoom'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const mockToolManager = vi.hoisted(() => ({
|
||||
switchTool: vi.fn()
|
||||
}))
|
||||
|
||||
const renderPanel = () =>
|
||||
render(ToolPanel, {
|
||||
global: { plugins: [i18n] },
|
||||
props: { toolManager: mockToolManager as unknown as ToolManager }
|
||||
})
|
||||
|
||||
const getToolButton = (tool: Tools): HTMLElement => {
|
||||
const btns = screen.getAllByTestId('tool-button')
|
||||
const match = btns.find((b) => b.dataset.tool === tool)
|
||||
if (!match) throw new Error(`tool button for "${tool}" not found`)
|
||||
return match
|
||||
}
|
||||
|
||||
describe('ToolPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockStore = initialMock()
|
||||
})
|
||||
|
||||
describe('tool list rendering', () => {
|
||||
it('should render one button per tool in allTools', () => {
|
||||
renderPanel()
|
||||
expect(screen.getAllByTestId('tool-button')).toHaveLength(allTools.length)
|
||||
})
|
||||
|
||||
it('should render the icon HTML for each tool', () => {
|
||||
renderPanel()
|
||||
for (const tool of allTools) {
|
||||
expect(screen.getByTestId(`icon-${tool}`)).toBeInTheDocument()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('current tool highlight', () => {
|
||||
it.each([Tools.MaskPen, Tools.Eraser, Tools.PaintPen] as const)(
|
||||
'should mark the %s button as selected when it is the current tool',
|
||||
(tool) => {
|
||||
mockStore.currentTool = tool
|
||||
renderPanel()
|
||||
|
||||
expect(getToolButton(tool).className).toContain(
|
||||
'maskEditor_toolPanelContainerSelected'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('should not mark non-current tools as selected', () => {
|
||||
mockStore.currentTool = Tools.MaskPen
|
||||
renderPanel()
|
||||
|
||||
for (const tool of allTools) {
|
||||
if (tool === Tools.MaskPen) continue
|
||||
expect(getToolButton(tool).className).not.toContain(
|
||||
'maskEditor_toolPanelContainerSelected'
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('tool selection', () => {
|
||||
it('should call toolManager.switchTool with the clicked tool', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderPanel()
|
||||
|
||||
await user.click(getToolButton(Tools.Eraser))
|
||||
|
||||
expect(mockToolManager.switchTool).toHaveBeenCalledWith(Tools.Eraser)
|
||||
})
|
||||
})
|
||||
|
||||
describe('zoom indicator', () => {
|
||||
it('should render rounded zoom percentage from displayZoomRatio', () => {
|
||||
mockStore.displayZoomRatio = 1.236
|
||||
renderPanel()
|
||||
|
||||
expect(screen.getByTestId('zoom-percentage').textContent).toBe('124%')
|
||||
})
|
||||
|
||||
it('should render image dimensions when an image is loaded', () => {
|
||||
mockStore.image = { width: 800, height: 600 }
|
||||
renderPanel()
|
||||
|
||||
expect(screen.getByTestId('zoom-dimensions').textContent).toBe('800x600')
|
||||
})
|
||||
|
||||
it('should render a single-space placeholder when no image', () => {
|
||||
mockStore.image = null
|
||||
renderPanel()
|
||||
|
||||
expect(screen.getByTestId('zoom-dimensions').textContent).toBe(' ')
|
||||
})
|
||||
|
||||
it('should call resetZoom when the zoom indicator is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderPanel()
|
||||
|
||||
await user.click(screen.getByTestId('zoom-percentage'))
|
||||
|
||||
expect(mockStore.resetZoom).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -4,8 +4,6 @@
|
||||
<div
|
||||
v-for="tool in allTools"
|
||||
:key="tool"
|
||||
data-testid="tool-button"
|
||||
:data-tool="tool"
|
||||
:class="[
|
||||
'maskEditor_toolPanelContainer hover:bg-secondary-background-hover',
|
||||
{ maskEditor_toolPanelContainerSelected: currentTool === tool }
|
||||
@@ -25,12 +23,8 @@
|
||||
:title="t('maskEditor.clickToResetZoom')"
|
||||
@click="onResetZoom"
|
||||
>
|
||||
<span data-testid="zoom-percentage" class="text-sm text-text-secondary">{{
|
||||
zoomText
|
||||
}}</span>
|
||||
<span data-testid="zoom-dimensions" class="text-xs text-text-secondary">{{
|
||||
dimensionsText
|
||||
}}</span>
|
||||
<span class="text-sm text-text-secondary">{{ zoomText }}</span>
|
||||
<span class="text-xs text-text-secondary">{{ dimensionsText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import DropdownControl from './DropdownControl.vue'
|
||||
|
||||
const renderComponent = (
|
||||
props: {
|
||||
label?: string
|
||||
options?: string[] | { label: string; value: string | number }[]
|
||||
modelValue?: string | number
|
||||
} = {},
|
||||
onUpdate?: (value: string | number) => void
|
||||
) => {
|
||||
const user = userEvent.setup()
|
||||
const utils = render(DropdownControl, {
|
||||
props: {
|
||||
label: 'Mode',
|
||||
options: ['One', 'Two', 'Three'],
|
||||
modelValue: 'One',
|
||||
...props,
|
||||
'onUpdate:modelValue': onUpdate
|
||||
}
|
||||
})
|
||||
return { user, ...utils }
|
||||
}
|
||||
|
||||
describe('DropdownControl', () => {
|
||||
it('should render the label', () => {
|
||||
renderComponent({ label: 'Brush Mode' })
|
||||
expect(screen.getByText('Brush Mode')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should expand string options to {label,value} pairs', () => {
|
||||
renderComponent({ options: ['Alpha', 'Beta'], modelValue: 'Alpha' })
|
||||
|
||||
const select = screen.getByRole('combobox') as HTMLSelectElement
|
||||
const values = Array.from(select.options).map((o) => o.value)
|
||||
const labels = Array.from(select.options).map((o) => o.textContent?.trim())
|
||||
|
||||
expect(values).toEqual(['Alpha', 'Beta'])
|
||||
expect(labels).toEqual(['Alpha', 'Beta'])
|
||||
})
|
||||
|
||||
it('should preserve {label,value} options as-is', () => {
|
||||
renderComponent({
|
||||
options: [
|
||||
{ label: 'High', value: 1 },
|
||||
{ label: 'Low', value: 2 }
|
||||
],
|
||||
modelValue: 1
|
||||
})
|
||||
|
||||
const select = screen.getByRole('combobox') as HTMLSelectElement
|
||||
expect(Array.from(select.options).map((o) => o.value)).toEqual(['1', '2'])
|
||||
expect(
|
||||
Array.from(select.options).map((o) => o.textContent?.trim())
|
||||
).toEqual(['High', 'Low'])
|
||||
})
|
||||
|
||||
it('should reflect modelValue as the selected option', () => {
|
||||
renderComponent({ options: ['One', 'Two'], modelValue: 'Two' })
|
||||
expect((screen.getByRole('combobox') as HTMLSelectElement).value).toBe(
|
||||
'Two'
|
||||
)
|
||||
})
|
||||
|
||||
it('should emit update:modelValue with the chosen string value', async () => {
|
||||
const onUpdate = vi.fn()
|
||||
const { user } = renderComponent(
|
||||
{ options: ['One', 'Two', 'Three'], modelValue: 'One' },
|
||||
onUpdate
|
||||
)
|
||||
|
||||
await user.selectOptions(screen.getByRole('combobox'), 'Three')
|
||||
|
||||
expect(onUpdate).toHaveBeenCalledWith('Three')
|
||||
})
|
||||
})
|
||||
@@ -1,64 +0,0 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import SliderControl from './SliderControl.vue'
|
||||
|
||||
const renderComponent = (
|
||||
props: {
|
||||
label?: string
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
modelValue?: number
|
||||
} = {},
|
||||
onUpdate?: (value: number) => void
|
||||
) => {
|
||||
return render(SliderControl, {
|
||||
props: {
|
||||
label: 'Brush Size',
|
||||
min: 1,
|
||||
max: 100,
|
||||
modelValue: 10,
|
||||
...props,
|
||||
'onUpdate:modelValue': onUpdate
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const setSliderValue = (input: HTMLInputElement, value: string): void => {
|
||||
input.value = value
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
}
|
||||
|
||||
describe('SliderControl', () => {
|
||||
it('should render the label', () => {
|
||||
renderComponent({ label: 'Hardness' })
|
||||
expect(screen.getByText('Hardness')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should expose min, max, step and modelValue on the input', () => {
|
||||
renderComponent({ min: 0, max: 50, step: 5, modelValue: 25 })
|
||||
|
||||
const input = screen.getByRole('slider') as HTMLInputElement
|
||||
expect(input.min).toBe('0')
|
||||
expect(input.max).toBe('50')
|
||||
expect(input.step).toBe('5')
|
||||
expect(input.value).toBe('25')
|
||||
})
|
||||
|
||||
it('should default step to 1 when not provided', () => {
|
||||
renderComponent({ min: 0, max: 10, modelValue: 5 })
|
||||
|
||||
expect((screen.getByRole('slider') as HTMLInputElement).step).toBe('1')
|
||||
})
|
||||
|
||||
it('should emit update:modelValue with a number when input changes', () => {
|
||||
const onUpdate = vi.fn()
|
||||
renderComponent({ min: 1, max: 100, modelValue: 10 }, onUpdate)
|
||||
|
||||
setSliderValue(screen.getByRole('slider') as HTMLInputElement, '42')
|
||||
|
||||
expect(onUpdate).toHaveBeenLastCalledWith(42)
|
||||
expect(typeof onUpdate.mock.calls.at(-1)![0]).toBe('number')
|
||||
})
|
||||
})
|
||||
@@ -1,60 +0,0 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import ToggleControl from './ToggleControl.vue'
|
||||
|
||||
const renderComponent = (
|
||||
props: { label?: string; modelValue?: boolean } = {},
|
||||
onUpdate?: (value: boolean) => void
|
||||
) => {
|
||||
const user = userEvent.setup()
|
||||
const utils = render(ToggleControl, {
|
||||
props: {
|
||||
label: 'Smoothing',
|
||||
modelValue: false,
|
||||
...props,
|
||||
'onUpdate:modelValue': onUpdate
|
||||
}
|
||||
})
|
||||
return { user, ...utils }
|
||||
}
|
||||
|
||||
describe('ToggleControl', () => {
|
||||
it('should render the label', () => {
|
||||
renderComponent({ label: 'Pressure Sensitivity' })
|
||||
expect(screen.getByText('Pressure Sensitivity')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should reflect modelValue=false as unchecked', () => {
|
||||
renderComponent({ modelValue: false })
|
||||
expect((screen.getByRole('checkbox') as HTMLInputElement).checked).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('should reflect modelValue=true as checked', () => {
|
||||
renderComponent({ modelValue: true })
|
||||
expect((screen.getByRole('checkbox') as HTMLInputElement).checked).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('should emit update:modelValue=true when toggled on', async () => {
|
||||
const onUpdate = vi.fn()
|
||||
const { user } = renderComponent({ modelValue: false }, onUpdate)
|
||||
|
||||
await user.click(screen.getByRole('checkbox'))
|
||||
|
||||
expect(onUpdate).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('should emit update:modelValue=false when toggled off', async () => {
|
||||
const onUpdate = vi.fn()
|
||||
const { user } = renderComponent({ modelValue: true }, onUpdate)
|
||||
|
||||
await user.click(screen.getByRole('checkbox'))
|
||||
|
||||
expect(onUpdate).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
@@ -1,262 +0,0 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { reactive } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import TopBarHeader from '@/components/maskeditor/dialog/TopBarHeader.vue'
|
||||
|
||||
const mockCanvasHistory = vi.hoisted(() => ({
|
||||
undo: vi.fn(),
|
||||
redo: vi.fn()
|
||||
}))
|
||||
|
||||
const initialMock = () =>
|
||||
reactive({
|
||||
canvasHistory: mockCanvasHistory,
|
||||
brushVisible: true,
|
||||
triggerClear: vi.fn()
|
||||
})
|
||||
|
||||
let mockStore: ReturnType<typeof initialMock>
|
||||
|
||||
const mockDialogStore = vi.hoisted(() => ({
|
||||
closeDialog: vi.fn()
|
||||
}))
|
||||
|
||||
const mockCanvasTools = vi.hoisted(() => ({
|
||||
invertMask: vi.fn(),
|
||||
clearMask: vi.fn()
|
||||
}))
|
||||
|
||||
const mockCanvasTransform = vi.hoisted(() => ({
|
||||
rotateCounterclockwise: vi.fn().mockResolvedValue(undefined),
|
||||
rotateClockwise: vi.fn().mockResolvedValue(undefined),
|
||||
mirrorHorizontal: vi.fn().mockResolvedValue(undefined),
|
||||
mirrorVertical: vi.fn().mockResolvedValue(undefined)
|
||||
}))
|
||||
|
||||
const mockSaver = vi.hoisted(() => ({
|
||||
save: vi.fn().mockResolvedValue(undefined)
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/maskEditorStore', () => ({
|
||||
useMaskEditorStore: () => mockStore
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: () => mockDialogStore
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/maskeditor/useCanvasTools', () => ({
|
||||
useCanvasTools: () => mockCanvasTools
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/maskeditor/useCanvasTransform', () => ({
|
||||
useCanvasTransform: () => mockCanvasTransform
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/maskeditor/useMaskEditorSaver', () => ({
|
||||
useMaskEditorSaver: () => mockSaver
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/button/Button.vue', () => ({
|
||||
default: {
|
||||
name: 'ButtonStub',
|
||||
props: ['variant', 'disabled'],
|
||||
template:
|
||||
'<button :data-variant="variant" :disabled="disabled"><slot /></button>'
|
||||
}
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: {
|
||||
save: 'Save',
|
||||
saving: 'Saving',
|
||||
cancel: 'Cancel'
|
||||
},
|
||||
maskEditor: {
|
||||
title: 'Mask Editor',
|
||||
invert: 'Invert',
|
||||
clear: 'Clear',
|
||||
undo: 'Undo',
|
||||
redo: 'Redo',
|
||||
rotateLeft: 'Rotate Left',
|
||||
rotateRight: 'Rotate Right',
|
||||
mirrorHorizontal: 'Mirror Horizontal',
|
||||
mirrorVertical: 'Mirror Vertical'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const renderHeader = () => render(TopBarHeader, { global: { plugins: [i18n] } })
|
||||
|
||||
describe('TopBarHeader', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockStore = initialMock()
|
||||
})
|
||||
|
||||
describe('title', () => {
|
||||
it('should render the localized title', () => {
|
||||
renderHeader()
|
||||
expect(screen.getByText('Mask Editor')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('history buttons', () => {
|
||||
it('should call canvasHistory.undo when undo button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderHeader()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Undo' }))
|
||||
|
||||
expect(mockCanvasHistory.undo).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call canvasHistory.redo when redo button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderHeader()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Redo' }))
|
||||
|
||||
expect(mockCanvasHistory.redo).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('canvas transform buttons', () => {
|
||||
it.each([
|
||||
['Rotate Left', 'rotateCounterclockwise'],
|
||||
['Rotate Right', 'rotateClockwise'],
|
||||
['Mirror Horizontal', 'mirrorHorizontal'],
|
||||
['Mirror Vertical', 'mirrorVertical']
|
||||
] as const)(
|
||||
'should call canvasTransform.%s when %s button is clicked',
|
||||
async (label, method) => {
|
||||
const user = userEvent.setup()
|
||||
renderHeader()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: label }))
|
||||
|
||||
expect(mockCanvasTransform[method]).toHaveBeenCalledTimes(1)
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
['Rotate Left', 'rotateCounterclockwise', 'Rotate left failed:'],
|
||||
['Rotate Right', 'rotateClockwise', 'Rotate right failed:'],
|
||||
['Mirror Horizontal', 'mirrorHorizontal', 'Mirror horizontal failed:'],
|
||||
['Mirror Vertical', 'mirrorVertical', 'Mirror vertical failed:']
|
||||
] as const)(
|
||||
'should swallow and log errors from %s',
|
||||
async (label, method, expectedMsg) => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
mockCanvasTransform[method].mockRejectedValueOnce(new Error('boom'))
|
||||
const user = userEvent.setup()
|
||||
renderHeader()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: label }))
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
`[TopBarHeader] ${expectedMsg}`,
|
||||
expect.any(Error)
|
||||
)
|
||||
errorSpy.mockRestore()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('mask edit buttons', () => {
|
||||
it('should call canvasTools.invertMask on Invert click', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderHeader()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Invert' }))
|
||||
|
||||
expect(mockCanvasTools.invertMask).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call clearMask and store.triggerClear on Clear click', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderHeader()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Clear' }))
|
||||
|
||||
expect(mockCanvasTools.clearMask).toHaveBeenCalledTimes(1)
|
||||
expect(mockStore.triggerClear).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('save', () => {
|
||||
it('should hide brush, save, and close the dialog on success', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderHeader()
|
||||
mockStore.brushVisible = true
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /save/i }))
|
||||
|
||||
expect(mockStore.brushVisible).toBe(false)
|
||||
expect(mockSaver.save).toHaveBeenCalledTimes(1)
|
||||
expect(mockDialogStore.closeDialog).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should switch the button text to "Saving" and disable the button while saving', async () => {
|
||||
let resolve!: () => void
|
||||
mockSaver.save.mockReturnValueOnce(
|
||||
new Promise<void>((r) => {
|
||||
resolve = r
|
||||
})
|
||||
)
|
||||
const user = userEvent.setup()
|
||||
renderHeader()
|
||||
|
||||
const clickPromise = user.click(
|
||||
screen.getByRole('button', { name: /save/i })
|
||||
)
|
||||
const savingBtn = await screen.findByRole('button', { name: 'Saving' })
|
||||
expect(savingBtn).toBeDisabled()
|
||||
|
||||
resolve()
|
||||
await clickPromise
|
||||
})
|
||||
|
||||
it('should restore brush + button state and log on save failure', async () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
mockSaver.save.mockRejectedValueOnce(new Error('save failed'))
|
||||
const user = userEvent.setup()
|
||||
renderHeader()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /save/i }))
|
||||
|
||||
expect(mockStore.brushVisible).toBe(true)
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
'[TopBarHeader] Save failed:',
|
||||
expect.any(Error)
|
||||
)
|
||||
expect(mockDialogStore.closeDialog).not.toHaveBeenCalled()
|
||||
// After failure, the Save button reads "Save" again (not "Saving")
|
||||
expect(
|
||||
screen.getByRole('button', { name: /save/i }).textContent?.trim()
|
||||
).toBe('Save')
|
||||
errorSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('cancel', () => {
|
||||
it('should close the dialog with the global-mask-editor key', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderHeader()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
|
||||
expect(mockDialogStore.closeDialog).toHaveBeenCalledWith({
|
||||
key: 'global-mask-editor'
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,380 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useCoordinateTransform } from '@/composables/maskeditor/useCoordinateTransform'
|
||||
|
||||
type MockStore = {
|
||||
pointerZone: HTMLElement | null
|
||||
canvasContainer: HTMLElement | null
|
||||
maskCanvas: HTMLCanvasElement | null
|
||||
}
|
||||
|
||||
const mockStore: MockStore = {
|
||||
pointerZone: null,
|
||||
canvasContainer: null,
|
||||
maskCanvas: null
|
||||
}
|
||||
|
||||
vi.mock('@/stores/maskEditorStore', () => ({
|
||||
useMaskEditorStore: vi.fn(() => mockStore)
|
||||
}))
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
createSharedComposable: <T extends (...args: unknown[]) => unknown>(fn: T) =>
|
||||
fn
|
||||
}))
|
||||
|
||||
const createElementWithRect = (rect: Partial<DOMRect>): HTMLElement => {
|
||||
const el = document.createElement('div')
|
||||
vi.spyOn(el, 'getBoundingClientRect').mockReturnValue({
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
...rect
|
||||
} as DOMRect)
|
||||
return el
|
||||
}
|
||||
|
||||
const createCanvasWithRect = (
|
||||
rect: Partial<DOMRect>,
|
||||
width: number,
|
||||
height: number
|
||||
): HTMLCanvasElement => {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
vi.spyOn(canvas, 'getBoundingClientRect').mockReturnValue({
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
...rect
|
||||
} as DOMRect)
|
||||
return canvas
|
||||
}
|
||||
|
||||
describe('useCoordinateTransform', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockStore.pointerZone = null
|
||||
mockStore.canvasContainer = null
|
||||
mockStore.maskCanvas = null
|
||||
})
|
||||
|
||||
describe('screenToCanvas', () => {
|
||||
it('should return canvas coordinates when display size matches canvas size', () => {
|
||||
mockStore.pointerZone = createElementWithRect({
|
||||
left: 100,
|
||||
top: 50,
|
||||
width: 200,
|
||||
height: 200
|
||||
})
|
||||
mockStore.canvasContainer = createElementWithRect({
|
||||
left: 100,
|
||||
top: 50,
|
||||
width: 200,
|
||||
height: 200
|
||||
})
|
||||
mockStore.maskCanvas = createCanvasWithRect(
|
||||
{ left: 100, top: 50, width: 200, height: 200 },
|
||||
200,
|
||||
200
|
||||
)
|
||||
|
||||
const transform = useCoordinateTransform()
|
||||
|
||||
expect(transform.screenToCanvas({ x: 50, y: 30 })).toEqual({
|
||||
x: 50,
|
||||
y: 30
|
||||
})
|
||||
})
|
||||
|
||||
it('should apply scale when canvas is rendered smaller than its bitmap', () => {
|
||||
mockStore.pointerZone = createElementWithRect({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 100,
|
||||
height: 100
|
||||
})
|
||||
mockStore.canvasContainer = createElementWithRect({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 100,
|
||||
height: 100
|
||||
})
|
||||
mockStore.maskCanvas = createCanvasWithRect(
|
||||
{ left: 0, top: 0, width: 100, height: 100 },
|
||||
400,
|
||||
400
|
||||
)
|
||||
|
||||
const transform = useCoordinateTransform()
|
||||
|
||||
expect(transform.screenToCanvas({ x: 25, y: 50 })).toEqual({
|
||||
x: 100,
|
||||
y: 200
|
||||
})
|
||||
})
|
||||
|
||||
it('should account for pointerZone offset relative to canvasContainer', () => {
|
||||
mockStore.pointerZone = createElementWithRect({
|
||||
left: 200,
|
||||
top: 100,
|
||||
width: 200,
|
||||
height: 200
|
||||
})
|
||||
mockStore.canvasContainer = createElementWithRect({
|
||||
left: 150,
|
||||
top: 80,
|
||||
width: 200,
|
||||
height: 200
|
||||
})
|
||||
mockStore.maskCanvas = createCanvasWithRect(
|
||||
{ left: 150, top: 80, width: 200, height: 200 },
|
||||
200,
|
||||
200
|
||||
)
|
||||
|
||||
const transform = useCoordinateTransform()
|
||||
|
||||
expect(transform.screenToCanvas({ x: 0, y: 0 })).toEqual({
|
||||
x: 50,
|
||||
y: 20
|
||||
})
|
||||
})
|
||||
|
||||
it('should support non-uniform scale factors', () => {
|
||||
mockStore.pointerZone = createElementWithRect({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 100,
|
||||
height: 50
|
||||
})
|
||||
mockStore.canvasContainer = createElementWithRect({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 100,
|
||||
height: 50
|
||||
})
|
||||
mockStore.maskCanvas = createCanvasWithRect(
|
||||
{ left: 0, top: 0, width: 100, height: 50 },
|
||||
200,
|
||||
200
|
||||
)
|
||||
|
||||
const transform = useCoordinateTransform()
|
||||
|
||||
expect(transform.screenToCanvas({ x: 10, y: 10 })).toEqual({
|
||||
x: 20,
|
||||
y: 40
|
||||
})
|
||||
})
|
||||
|
||||
it('should return zero point and warn when pointerZone is missing', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
mockStore.canvasContainer = createElementWithRect({})
|
||||
mockStore.maskCanvas = createCanvasWithRect({}, 100, 100)
|
||||
|
||||
const transform = useCoordinateTransform()
|
||||
|
||||
expect(transform.screenToCanvas({ x: 10, y: 20 })).toEqual({ x: 0, y: 0 })
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'screenToCanvas called before elements are available'
|
||||
)
|
||||
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should return zero point when canvasContainer is missing', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
mockStore.pointerZone = createElementWithRect({})
|
||||
mockStore.maskCanvas = createCanvasWithRect({}, 100, 100)
|
||||
|
||||
const transform = useCoordinateTransform()
|
||||
|
||||
expect(transform.screenToCanvas({ x: 10, y: 20 })).toEqual({ x: 0, y: 0 })
|
||||
expect(warnSpy).toHaveBeenCalled()
|
||||
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should return zero point when maskCanvas is missing', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
mockStore.pointerZone = createElementWithRect({})
|
||||
mockStore.canvasContainer = createElementWithRect({})
|
||||
|
||||
const transform = useCoordinateTransform()
|
||||
|
||||
expect(transform.screenToCanvas({ x: 10, y: 20 })).toEqual({ x: 0, y: 0 })
|
||||
expect(warnSpy).toHaveBeenCalled()
|
||||
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('canvasToScreen', () => {
|
||||
it('should return pointerZone-relative coordinates when display matches bitmap', () => {
|
||||
mockStore.pointerZone = createElementWithRect({
|
||||
left: 100,
|
||||
top: 50,
|
||||
width: 200,
|
||||
height: 200
|
||||
})
|
||||
mockStore.canvasContainer = createElementWithRect({
|
||||
left: 100,
|
||||
top: 50,
|
||||
width: 200,
|
||||
height: 200
|
||||
})
|
||||
mockStore.maskCanvas = createCanvasWithRect(
|
||||
{ left: 100, top: 50, width: 200, height: 200 },
|
||||
200,
|
||||
200
|
||||
)
|
||||
|
||||
const transform = useCoordinateTransform()
|
||||
|
||||
expect(transform.canvasToScreen({ x: 50, y: 30 })).toEqual({
|
||||
x: 50,
|
||||
y: 30
|
||||
})
|
||||
})
|
||||
|
||||
it('should apply inverse scale when canvas bitmap is larger than display', () => {
|
||||
mockStore.pointerZone = createElementWithRect({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 100,
|
||||
height: 100
|
||||
})
|
||||
mockStore.canvasContainer = createElementWithRect({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 100,
|
||||
height: 100
|
||||
})
|
||||
mockStore.maskCanvas = createCanvasWithRect(
|
||||
{ left: 0, top: 0, width: 100, height: 100 },
|
||||
400,
|
||||
400
|
||||
)
|
||||
|
||||
const transform = useCoordinateTransform()
|
||||
|
||||
expect(transform.canvasToScreen({ x: 100, y: 200 })).toEqual({
|
||||
x: 25,
|
||||
y: 50
|
||||
})
|
||||
})
|
||||
|
||||
it('should account for pointerZone offset relative to canvasContainer', () => {
|
||||
mockStore.pointerZone = createElementWithRect({
|
||||
left: 200,
|
||||
top: 100,
|
||||
width: 200,
|
||||
height: 200
|
||||
})
|
||||
mockStore.canvasContainer = createElementWithRect({
|
||||
left: 150,
|
||||
top: 80,
|
||||
width: 200,
|
||||
height: 200
|
||||
})
|
||||
mockStore.maskCanvas = createCanvasWithRect(
|
||||
{ left: 150, top: 80, width: 200, height: 200 },
|
||||
200,
|
||||
200
|
||||
)
|
||||
|
||||
const transform = useCoordinateTransform()
|
||||
|
||||
expect(transform.canvasToScreen({ x: 50, y: 20 })).toEqual({ x: 0, y: 0 })
|
||||
})
|
||||
|
||||
it('should round-trip with screenToCanvas', () => {
|
||||
mockStore.pointerZone = createElementWithRect({
|
||||
left: 50,
|
||||
top: 25,
|
||||
width: 300,
|
||||
height: 300
|
||||
})
|
||||
mockStore.canvasContainer = createElementWithRect({
|
||||
left: 70,
|
||||
top: 40,
|
||||
width: 300,
|
||||
height: 300
|
||||
})
|
||||
mockStore.maskCanvas = createCanvasWithRect(
|
||||
{ left: 70, top: 40, width: 300, height: 300 },
|
||||
600,
|
||||
600
|
||||
)
|
||||
|
||||
const transform = useCoordinateTransform()
|
||||
const original = { x: 123, y: 87 }
|
||||
|
||||
const canvasPoint = transform.screenToCanvas(original)
|
||||
const back = transform.canvasToScreen(canvasPoint)
|
||||
|
||||
expect(back.x).toBeCloseTo(original.x)
|
||||
expect(back.y).toBeCloseTo(original.y)
|
||||
})
|
||||
|
||||
it('should return zero point and warn when pointerZone is missing', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
mockStore.canvasContainer = createElementWithRect({})
|
||||
mockStore.maskCanvas = createCanvasWithRect({}, 100, 100)
|
||||
|
||||
const transform = useCoordinateTransform()
|
||||
|
||||
expect(transform.canvasToScreen({ x: 10, y: 20 })).toEqual({ x: 0, y: 0 })
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'canvasToScreen called before elements are available'
|
||||
)
|
||||
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should return zero point when canvasContainer is missing', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
mockStore.pointerZone = createElementWithRect({})
|
||||
mockStore.maskCanvas = createCanvasWithRect({}, 100, 100)
|
||||
|
||||
const transform = useCoordinateTransform()
|
||||
|
||||
expect(transform.canvasToScreen({ x: 10, y: 20 })).toEqual({ x: 0, y: 0 })
|
||||
expect(warnSpy).toHaveBeenCalled()
|
||||
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should return zero point when maskCanvas is missing', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
mockStore.pointerZone = createElementWithRect({})
|
||||
mockStore.canvasContainer = createElementWithRect({})
|
||||
|
||||
const transform = useCoordinateTransform()
|
||||
|
||||
expect(transform.canvasToScreen({ x: 10, y: 20 })).toEqual({ x: 0, y: 0 })
|
||||
expect(warnSpy).toHaveBeenCalled()
|
||||
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,209 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useKeyboard } from '@/composables/maskeditor/useKeyboard'
|
||||
|
||||
type MockCanvasHistory = {
|
||||
undo: ReturnType<typeof vi.fn>
|
||||
redo: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
type MockStore = {
|
||||
canvasHistory: MockCanvasHistory
|
||||
}
|
||||
|
||||
const { mockStore, mockCanvasHistory } = vi.hoisted(() => {
|
||||
const mockCanvasHistory: MockCanvasHistory = {
|
||||
undo: vi.fn(),
|
||||
redo: vi.fn()
|
||||
}
|
||||
|
||||
const mockStore: MockStore = {
|
||||
canvasHistory: mockCanvasHistory
|
||||
}
|
||||
|
||||
return { mockStore, mockCanvasHistory }
|
||||
})
|
||||
|
||||
vi.mock('@/stores/maskEditorStore', () => ({
|
||||
useMaskEditorStore: vi.fn(() => mockStore)
|
||||
}))
|
||||
|
||||
const dispatchKeyDown = (
|
||||
init: KeyboardEventInit & { key: string }
|
||||
): KeyboardEvent => {
|
||||
const event = new KeyboardEvent('keydown', { cancelable: true, ...init })
|
||||
document.dispatchEvent(event)
|
||||
return event
|
||||
}
|
||||
|
||||
const dispatchKeyUp = (key: string): void => {
|
||||
document.dispatchEvent(new KeyboardEvent('keyup', { key }))
|
||||
}
|
||||
|
||||
describe('useKeyboard', () => {
|
||||
let keyboard: ReturnType<typeof useKeyboard>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
document.body.innerHTML = ''
|
||||
keyboard = useKeyboard()
|
||||
keyboard.addListeners()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
keyboard.removeListeners()
|
||||
})
|
||||
|
||||
describe('isKeyDown', () => {
|
||||
it('should return false for keys that have not been pressed', () => {
|
||||
expect(keyboard.isKeyDown('a')).toBe(false)
|
||||
})
|
||||
|
||||
it('should return true after a key is pressed', () => {
|
||||
dispatchKeyDown({ key: 'a' })
|
||||
|
||||
expect(keyboard.isKeyDown('a')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false after a pressed key is released', () => {
|
||||
dispatchKeyDown({ key: 'a' })
|
||||
dispatchKeyUp('a')
|
||||
|
||||
expect(keyboard.isKeyDown('a')).toBe(false)
|
||||
})
|
||||
|
||||
it('should track multiple keys independently', () => {
|
||||
dispatchKeyDown({ key: 'a' })
|
||||
dispatchKeyDown({ key: 'b' })
|
||||
|
||||
expect(keyboard.isKeyDown('a')).toBe(true)
|
||||
expect(keyboard.isKeyDown('b')).toBe(true)
|
||||
|
||||
dispatchKeyUp('a')
|
||||
|
||||
expect(keyboard.isKeyDown('a')).toBe(false)
|
||||
expect(keyboard.isKeyDown('b')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleKeyDown', () => {
|
||||
it('should not duplicate the same key on repeated keydown events', () => {
|
||||
dispatchKeyDown({ key: 'a' })
|
||||
dispatchKeyDown({ key: 'a' })
|
||||
dispatchKeyDown({ key: 'a' })
|
||||
dispatchKeyUp('a')
|
||||
|
||||
expect(keyboard.isKeyDown('a')).toBe(false)
|
||||
})
|
||||
|
||||
it('should prevent default and blur the active element on space', () => {
|
||||
const input = document.createElement('input')
|
||||
document.body.appendChild(input)
|
||||
input.focus()
|
||||
const blurSpy = vi.spyOn(input, 'blur')
|
||||
|
||||
const event = dispatchKeyDown({ key: ' ' })
|
||||
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
expect(blurSpy).toHaveBeenCalledTimes(1)
|
||||
expect(keyboard.isKeyDown(' ')).toBe(true)
|
||||
})
|
||||
|
||||
it('should not throw when activeElement is null', () => {
|
||||
Object.defineProperty(document, 'activeElement', {
|
||||
value: null,
|
||||
configurable: true
|
||||
})
|
||||
|
||||
try {
|
||||
expect(() => dispatchKeyDown({ key: ' ' })).not.toThrow()
|
||||
} finally {
|
||||
Reflect.deleteProperty(document, 'activeElement')
|
||||
}
|
||||
})
|
||||
|
||||
it('should call undo on Ctrl+Z without shift', () => {
|
||||
dispatchKeyDown({ key: 'z', ctrlKey: true })
|
||||
|
||||
expect(mockCanvasHistory.undo).toHaveBeenCalledTimes(1)
|
||||
expect(mockCanvasHistory.redo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call undo on Meta+Z without shift', () => {
|
||||
dispatchKeyDown({ key: 'z', metaKey: true })
|
||||
|
||||
expect(mockCanvasHistory.undo).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call redo on Ctrl+Shift+Z', () => {
|
||||
dispatchKeyDown({ key: 'Z', ctrlKey: true, shiftKey: true })
|
||||
|
||||
expect(mockCanvasHistory.redo).toHaveBeenCalledTimes(1)
|
||||
expect(mockCanvasHistory.undo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call redo on Ctrl+Y', () => {
|
||||
dispatchKeyDown({ key: 'y', ctrlKey: true })
|
||||
|
||||
expect(mockCanvasHistory.redo).toHaveBeenCalledTimes(1)
|
||||
expect(mockCanvasHistory.undo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not trigger undo or redo when alt is held', () => {
|
||||
dispatchKeyDown({ key: 'z', ctrlKey: true, altKey: true })
|
||||
dispatchKeyDown({ key: 'y', ctrlKey: true, altKey: true })
|
||||
|
||||
expect(mockCanvasHistory.undo).not.toHaveBeenCalled()
|
||||
expect(mockCanvasHistory.redo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not trigger undo or redo without ctrl or meta', () => {
|
||||
dispatchKeyDown({ key: 'z' })
|
||||
dispatchKeyDown({ key: 'y' })
|
||||
dispatchKeyDown({ key: 'Z', shiftKey: true })
|
||||
|
||||
expect(mockCanvasHistory.undo).not.toHaveBeenCalled()
|
||||
expect(mockCanvasHistory.redo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should ignore Ctrl+Shift+Y', () => {
|
||||
dispatchKeyDown({ key: 'Y', ctrlKey: true, shiftKey: true })
|
||||
|
||||
expect(mockCanvasHistory.redo).not.toHaveBeenCalled()
|
||||
expect(mockCanvasHistory.undo).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('addListeners', () => {
|
||||
it('should clear all tracked keys when the window loses focus', () => {
|
||||
dispatchKeyDown({ key: 'a' })
|
||||
dispatchKeyDown({ key: 'b' })
|
||||
|
||||
window.dispatchEvent(new Event('blur'))
|
||||
|
||||
expect(keyboard.isKeyDown('a')).toBe(false)
|
||||
expect(keyboard.isKeyDown('b')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeListeners', () => {
|
||||
it('should stop responding to keyboard events after removal', () => {
|
||||
keyboard.removeListeners()
|
||||
|
||||
dispatchKeyDown({ key: 'a' })
|
||||
dispatchKeyDown({ key: 'z', ctrlKey: true })
|
||||
|
||||
expect(keyboard.isKeyDown('a')).toBe(false)
|
||||
expect(mockCanvasHistory.undo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should stop clearing keys on window blur after removal', () => {
|
||||
dispatchKeyDown({ key: 'a' })
|
||||
keyboard.removeListeners()
|
||||
|
||||
window.dispatchEvent(new Event('blur'))
|
||||
|
||||
expect(keyboard.isKeyDown('a')).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,118 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
|
||||
const mockDialogStore = vi.hoisted(() => ({
|
||||
showDialog: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: () => mockDialogStore
|
||||
}))
|
||||
|
||||
vi.mock('@/components/maskeditor/dialog/TopBarHeader.vue', () => ({
|
||||
default: { name: 'TopBarHeaderStub' }
|
||||
}))
|
||||
|
||||
vi.mock('@/components/maskeditor/MaskEditorContent.vue', () => ({
|
||||
default: { name: 'MaskEditorContentStub' }
|
||||
}))
|
||||
|
||||
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
|
||||
|
||||
type NodeShape = {
|
||||
imgs?: unknown[]
|
||||
previewMediaType?: string
|
||||
}
|
||||
|
||||
const nodeWithImage = (overrides: NodeShape = {}): LGraphNode =>
|
||||
({
|
||||
imgs: [new Image()],
|
||||
previewMediaType: undefined,
|
||||
...overrides
|
||||
}) as unknown as LGraphNode
|
||||
|
||||
describe('useMaskEditor', () => {
|
||||
let errorSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
describe('openMaskEditor', () => {
|
||||
it('should open the dialog with the node forwarded as a prop', () => {
|
||||
const node = nodeWithImage()
|
||||
|
||||
useMaskEditor().openMaskEditor(node)
|
||||
|
||||
expect(mockDialogStore.showDialog).toHaveBeenCalledTimes(1)
|
||||
expect(mockDialogStore.showDialog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
key: 'global-mask-editor',
|
||||
props: { node }
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should pass header and content components to the dialog', () => {
|
||||
useMaskEditor().openMaskEditor(nodeWithImage())
|
||||
|
||||
expect(mockDialogStore.showDialog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
headerComponent: expect.anything(),
|
||||
component: expect.anything()
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should configure modal dialog with maximizable and closable flags', () => {
|
||||
useMaskEditor().openMaskEditor(nodeWithImage())
|
||||
|
||||
expect(mockDialogStore.showDialog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
dialogComponentProps: expect.objectContaining({
|
||||
modal: true,
|
||||
maximizable: true,
|
||||
closable: true
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should accept a node whose previewMediaType is "image" without imgs', () => {
|
||||
const node = nodeWithImage({
|
||||
imgs: undefined,
|
||||
previewMediaType: 'image'
|
||||
})
|
||||
|
||||
useMaskEditor().openMaskEditor(node)
|
||||
|
||||
expect(mockDialogStore.showDialog).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should log and bail when node is null', () => {
|
||||
useMaskEditor().openMaskEditor(null as unknown as LGraphNode)
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledWith('[MaskEditor] No node provided')
|
||||
expect(mockDialogStore.showDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should log and bail when node has neither imgs nor image preview', () => {
|
||||
const node = nodeWithImage({ imgs: [], previewMediaType: undefined })
|
||||
|
||||
useMaskEditor().openMaskEditor(node)
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledWith('[MaskEditor] Node has no images')
|
||||
expect(mockDialogStore.showDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should bail when node has empty imgs and a non-image preview type', () => {
|
||||
const node = nodeWithImage({ imgs: [], previewMediaType: 'video' })
|
||||
|
||||
useMaskEditor().openMaskEditor(node)
|
||||
|
||||
expect(mockDialogStore.showDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,517 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { effectScope, nextTick, reactive } from 'vue'
|
||||
import type { EffectScope } from 'vue'
|
||||
|
||||
import { useBrushDrawing } from '@/composables/maskeditor/useBrushDrawing'
|
||||
import { useToolManager } from '@/composables/maskeditor/useToolManager'
|
||||
import { Tools } from '@/extensions/core/maskeditor/types'
|
||||
|
||||
type MockStore = {
|
||||
currentTool: Tools
|
||||
activeLayer: 'mask' | 'rgb'
|
||||
pointerZone: HTMLElement | null
|
||||
brushVisible: boolean
|
||||
brushPreviewGradientVisible: boolean
|
||||
isAdjustingBrush: boolean
|
||||
isPanning: boolean
|
||||
}
|
||||
|
||||
const mockStore: MockStore = reactive({
|
||||
currentTool: Tools.MaskPen,
|
||||
activeLayer: 'mask',
|
||||
pointerZone: null,
|
||||
brushVisible: true,
|
||||
brushPreviewGradientVisible: false,
|
||||
isAdjustingBrush: false,
|
||||
isPanning: false
|
||||
}) as MockStore
|
||||
|
||||
const mockBrushDrawing = {
|
||||
startDrawing: vi.fn().mockResolvedValue(undefined),
|
||||
handleDrawing: vi.fn().mockResolvedValue(undefined),
|
||||
drawEnd: vi.fn().mockResolvedValue(undefined),
|
||||
startBrushAdjustment: vi.fn().mockResolvedValue(undefined),
|
||||
handleBrushAdjustment: vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
|
||||
const mockCanvasTools = {
|
||||
paintBucketFill: vi.fn(),
|
||||
colorSelectFill: vi.fn().mockResolvedValue(undefined),
|
||||
clearLastColorSelectPoint: vi.fn()
|
||||
}
|
||||
|
||||
const mockCoordinateTransform = {
|
||||
screenToCanvas: vi.fn((p: { x: number; y: number }) => ({
|
||||
x: p.x * 2,
|
||||
y: p.y * 2
|
||||
})),
|
||||
canvasToScreen: vi.fn()
|
||||
}
|
||||
|
||||
vi.mock('@/stores/maskEditorStore', () => ({
|
||||
useMaskEditorStore: vi.fn(() => mockStore)
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/maskeditor/useBrushDrawing', () => ({
|
||||
useBrushDrawing: vi.fn(() => mockBrushDrawing)
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/maskeditor/useCanvasTools', () => ({
|
||||
useCanvasTools: vi.fn(() => mockCanvasTools)
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/maskeditor/useCoordinateTransform', () => ({
|
||||
useCoordinateTransform: vi.fn(() => mockCoordinateTransform)
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
extensionManager: {
|
||||
setting: {
|
||||
get: vi.fn((key: string) => {
|
||||
if (key === 'Comfy.MaskEditor.UseDominantAxis') return false
|
||||
if (key === 'Comfy.MaskEditor.BrushAdjustmentSpeed') return 1
|
||||
return undefined
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
const mockKeyboard = {
|
||||
isKeyDown: vi.fn().mockReturnValue(false),
|
||||
addListeners: vi.fn(),
|
||||
removeListeners: vi.fn()
|
||||
}
|
||||
|
||||
const mockPanZoom = {
|
||||
initializeCanvasPanZoom: vi.fn(),
|
||||
handlePanStart: vi.fn(),
|
||||
handlePanMove: vi.fn().mockResolvedValue(undefined),
|
||||
handleTouchStart: vi.fn(),
|
||||
handleTouchMove: vi.fn(),
|
||||
handleTouchEnd: vi.fn(),
|
||||
updateCursorPosition: vi.fn(),
|
||||
zoom: vi.fn(),
|
||||
invalidatePanZoom: vi.fn(),
|
||||
addPenPointerId: vi.fn(),
|
||||
removePenPointerId: vi.fn()
|
||||
}
|
||||
|
||||
const pointerEvent = (
|
||||
init: Partial<PointerEvent> & { pointerType?: string }
|
||||
): PointerEvent => {
|
||||
return {
|
||||
preventDefault: vi.fn(),
|
||||
pointerId: 1,
|
||||
pointerType: 'mouse',
|
||||
button: 0,
|
||||
buttons: 0,
|
||||
clientX: 0,
|
||||
clientY: 0,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
altKey: false,
|
||||
...init
|
||||
} as unknown as PointerEvent
|
||||
}
|
||||
|
||||
let scope: EffectScope | null = null
|
||||
|
||||
const setup = (): ReturnType<typeof useToolManager> => {
|
||||
scope = effectScope()
|
||||
return scope.run(() =>
|
||||
useToolManager(
|
||||
mockKeyboard as unknown as Parameters<typeof useToolManager>[0],
|
||||
mockPanZoom as unknown as Parameters<typeof useToolManager>[1]
|
||||
)
|
||||
)!
|
||||
}
|
||||
|
||||
describe('useToolManager', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockStore.currentTool = Tools.MaskPen
|
||||
mockStore.activeLayer = 'mask'
|
||||
mockStore.pointerZone = document.createElement('div')
|
||||
mockStore.brushVisible = true
|
||||
mockStore.brushPreviewGradientVisible = false
|
||||
mockStore.isAdjustingBrush = false
|
||||
mockStore.isPanning = false
|
||||
mockKeyboard.isKeyDown.mockReturnValue(false)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
scope?.stop()
|
||||
scope = null
|
||||
})
|
||||
|
||||
describe('useBrushDrawing factory', () => {
|
||||
it('should construct useBrushDrawing with settings from the extension manager', () => {
|
||||
setup()
|
||||
|
||||
expect(useBrushDrawing).toHaveBeenCalledWith({
|
||||
useDominantAxis: false,
|
||||
brushAdjustmentSpeed: 1
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('switchTool', () => {
|
||||
it('should set the current tool on the store', () => {
|
||||
const tm = setup()
|
||||
tm.switchTool(Tools.Eraser)
|
||||
expect(mockStore.currentTool).toBe(Tools.Eraser)
|
||||
})
|
||||
|
||||
it('should update activeLayer to "rgb" when switching to PaintPen', () => {
|
||||
const tm = setup()
|
||||
tm.switchTool(Tools.PaintPen)
|
||||
expect(mockStore.activeLayer).toBe('rgb')
|
||||
})
|
||||
|
||||
it('should update activeLayer to "mask" when switching to MaskPen', () => {
|
||||
const tm = setup()
|
||||
mockStore.activeLayer = 'rgb'
|
||||
tm.switchTool(Tools.MaskPen)
|
||||
expect(mockStore.activeLayer).toBe('mask')
|
||||
})
|
||||
|
||||
it('should set custom cursor and hide brush for MaskBucket', () => {
|
||||
const tm = setup()
|
||||
tm.switchTool(Tools.MaskBucket)
|
||||
expect(mockStore.brushVisible).toBe(false)
|
||||
expect(mockStore.pointerZone!.style.cursor).toContain('paintBucket.png')
|
||||
})
|
||||
|
||||
it('should reset cursor to "none" and show brush for tools without custom cursor', () => {
|
||||
const tm = setup()
|
||||
tm.switchTool(Tools.MaskBucket)
|
||||
expect(mockStore.brushVisible).toBe(false)
|
||||
|
||||
tm.switchTool(Tools.MaskPen)
|
||||
expect(mockStore.brushVisible).toBe(true)
|
||||
expect(mockStore.pointerZone!.style.cursor).toBe('none')
|
||||
})
|
||||
|
||||
it('should not touch cursor when pointerZone is missing', () => {
|
||||
const tm = setup()
|
||||
mockStore.pointerZone = null
|
||||
expect(() => tm.switchTool(Tools.MaskBucket)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('setActiveLayer', () => {
|
||||
it('should switch from mask-only tool to PaintPen when activating rgb layer', () => {
|
||||
const tm = setup()
|
||||
mockStore.currentTool = Tools.MaskBucket
|
||||
|
||||
tm.setActiveLayer('rgb')
|
||||
|
||||
expect(mockStore.activeLayer).toBe('rgb')
|
||||
expect(mockStore.currentTool).toBe(Tools.PaintPen)
|
||||
})
|
||||
|
||||
it('should switch from PaintPen to MaskPen when activating mask layer', () => {
|
||||
const tm = setup()
|
||||
mockStore.currentTool = Tools.PaintPen
|
||||
|
||||
tm.setActiveLayer('mask')
|
||||
|
||||
expect(mockStore.activeLayer).toBe('mask')
|
||||
expect(mockStore.currentTool).toBe(Tools.MaskPen)
|
||||
})
|
||||
|
||||
it('should leave a non-mask-only tool alone when activating rgb', () => {
|
||||
const tm = setup()
|
||||
mockStore.currentTool = Tools.Eraser
|
||||
|
||||
tm.setActiveLayer('rgb')
|
||||
|
||||
expect(mockStore.currentTool).toBe(Tools.Eraser)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateCursor', () => {
|
||||
it('should hide brush and set custom cursor for tools that define one', () => {
|
||||
const tm = setup()
|
||||
mockStore.currentTool = Tools.MaskColorFill
|
||||
|
||||
tm.updateCursor()
|
||||
|
||||
expect(mockStore.brushVisible).toBe(false)
|
||||
expect(mockStore.pointerZone!.style.cursor).toContain('colorSelect.png')
|
||||
expect(mockStore.brushPreviewGradientVisible).toBe(false)
|
||||
})
|
||||
|
||||
it('should show brush and "none" cursor for tools without a custom cursor', () => {
|
||||
const tm = setup()
|
||||
mockStore.currentTool = Tools.MaskPen
|
||||
|
||||
tm.updateCursor()
|
||||
|
||||
expect(mockStore.brushVisible).toBe(true)
|
||||
expect(mockStore.pointerZone!.style.cursor).toBe('none')
|
||||
})
|
||||
})
|
||||
|
||||
describe('currentTool watcher', () => {
|
||||
it('should clear last color-select point when switching away from MaskColorFill', async () => {
|
||||
setup()
|
||||
mockStore.currentTool = Tools.MaskColorFill
|
||||
await nextTick()
|
||||
mockCanvasTools.clearLastColorSelectPoint.mockClear()
|
||||
|
||||
mockStore.currentTool = Tools.MaskPen
|
||||
await nextTick()
|
||||
|
||||
expect(mockCanvasTools.clearLastColorSelectPoint).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not clear color-select point when switching to MaskColorFill', async () => {
|
||||
setup()
|
||||
mockStore.currentTool = Tools.MaskPen
|
||||
await nextTick()
|
||||
mockCanvasTools.clearLastColorSelectPoint.mockClear()
|
||||
|
||||
mockStore.currentTool = Tools.MaskColorFill
|
||||
await nextTick()
|
||||
|
||||
expect(mockCanvasTools.clearLastColorSelectPoint).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('handlePointerDown', () => {
|
||||
it('should ignore touch pointers entirely', async () => {
|
||||
const tm = setup()
|
||||
await tm.handlePointerDown(pointerEvent({ pointerType: 'touch' }))
|
||||
|
||||
expect(mockBrushDrawing.startDrawing).not.toHaveBeenCalled()
|
||||
expect(mockPanZoom.handlePanStart).not.toHaveBeenCalled()
|
||||
expect(mockPanZoom.addPenPointerId).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should register pen pointer id then continue tool routing', async () => {
|
||||
const tm = setup()
|
||||
await tm.handlePointerDown(
|
||||
pointerEvent({
|
||||
pointerType: 'pen',
|
||||
button: 0,
|
||||
buttons: 1,
|
||||
pointerId: 7
|
||||
})
|
||||
)
|
||||
|
||||
expect(mockPanZoom.addPenPointerId).toHaveBeenCalledWith(7)
|
||||
})
|
||||
|
||||
it('should start panning on middle mouse button (buttons===4)', async () => {
|
||||
const tm = setup()
|
||||
await tm.handlePointerDown(pointerEvent({ buttons: 4 }))
|
||||
|
||||
expect(mockPanZoom.handlePanStart).toHaveBeenCalled()
|
||||
expect(mockStore.brushVisible).toBe(false)
|
||||
expect(mockBrushDrawing.startDrawing).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should start panning on left button + space held', async () => {
|
||||
const tm = setup()
|
||||
mockKeyboard.isKeyDown.mockImplementation((k) => k === ' ')
|
||||
|
||||
await tm.handlePointerDown(pointerEvent({ buttons: 1 }))
|
||||
|
||||
expect(mockPanZoom.handlePanStart).toHaveBeenCalled()
|
||||
expect(mockBrushDrawing.startDrawing).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should start drawing for MaskPen on left button', async () => {
|
||||
const tm = setup()
|
||||
mockStore.currentTool = Tools.MaskPen
|
||||
|
||||
await tm.handlePointerDown(pointerEvent({ button: 0, buttons: 1 }))
|
||||
|
||||
expect(mockBrushDrawing.startDrawing).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should start drawing for PaintPen on left button', async () => {
|
||||
const tm = setup()
|
||||
mockStore.currentTool = Tools.PaintPen
|
||||
|
||||
await tm.handlePointerDown(pointerEvent({ button: 0, buttons: 1 }))
|
||||
|
||||
expect(mockBrushDrawing.startDrawing).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should continue drawing for PaintPen when a non-left button fires while left is held', async () => {
|
||||
const tm = setup()
|
||||
mockStore.currentTool = Tools.PaintPen
|
||||
|
||||
await tm.handlePointerDown(pointerEvent({ button: 2, buttons: 1 }))
|
||||
|
||||
expect(mockBrushDrawing.handleDrawing).toHaveBeenCalledTimes(1)
|
||||
expect(mockBrushDrawing.startDrawing).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call paintBucketFill on MaskBucket left click using transformed coords', async () => {
|
||||
const tm = setup()
|
||||
mockStore.currentTool = Tools.MaskBucket
|
||||
|
||||
await tm.handlePointerDown(
|
||||
pointerEvent({ button: 0, offsetX: 50, offsetY: 25 })
|
||||
)
|
||||
|
||||
expect(mockCoordinateTransform.screenToCanvas).toHaveBeenCalledWith({
|
||||
x: 50,
|
||||
y: 25
|
||||
})
|
||||
expect(mockCanvasTools.paintBucketFill).toHaveBeenCalledWith({
|
||||
x: 100,
|
||||
y: 50
|
||||
})
|
||||
})
|
||||
|
||||
it('should call colorSelectFill on MaskColorFill left click', async () => {
|
||||
const tm = setup()
|
||||
mockStore.currentTool = Tools.MaskColorFill
|
||||
|
||||
await tm.handlePointerDown(
|
||||
pointerEvent({ button: 0, offsetX: 10, offsetY: 20 })
|
||||
)
|
||||
|
||||
expect(mockCanvasTools.colorSelectFill).toHaveBeenCalledWith({
|
||||
x: 20,
|
||||
y: 40
|
||||
})
|
||||
})
|
||||
|
||||
it('should start brush adjustment on alt + right-click', async () => {
|
||||
const tm = setup()
|
||||
|
||||
await tm.handlePointerDown(
|
||||
pointerEvent({ altKey: true, button: 2, buttons: 2 })
|
||||
)
|
||||
|
||||
expect(mockStore.isAdjustingBrush).toBe(true)
|
||||
expect(mockBrushDrawing.startBrushAdjustment).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should start drawing on right-click for drawing tools', async () => {
|
||||
const tm = setup()
|
||||
mockStore.currentTool = Tools.Eraser
|
||||
|
||||
await tm.handlePointerDown(pointerEvent({ button: 2, buttons: 2 }))
|
||||
|
||||
expect(mockBrushDrawing.startDrawing).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not start drawing for non-drawing tools', async () => {
|
||||
const tm = setup()
|
||||
mockStore.currentTool = Tools.MaskBucket
|
||||
|
||||
await tm.handlePointerDown(pointerEvent({ button: 2, buttons: 2 }))
|
||||
|
||||
expect(mockBrushDrawing.startDrawing).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('handlePointerMove', () => {
|
||||
it('should ignore touch pointers', async () => {
|
||||
const tm = setup()
|
||||
await tm.handlePointerMove(pointerEvent({ pointerType: 'touch' }))
|
||||
|
||||
expect(mockPanZoom.updateCursorPosition).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should always update cursor position for non-touch pointers', async () => {
|
||||
const tm = setup()
|
||||
await tm.handlePointerMove(pointerEvent({ clientX: 30, clientY: 40 }))
|
||||
|
||||
expect(mockPanZoom.updateCursorPosition).toHaveBeenCalledWith({
|
||||
x: 30,
|
||||
y: 40
|
||||
})
|
||||
})
|
||||
|
||||
it('should pan on middle button drag', async () => {
|
||||
const tm = setup()
|
||||
await tm.handlePointerMove(pointerEvent({ buttons: 4 }))
|
||||
|
||||
expect(mockPanZoom.handlePanMove).toHaveBeenCalled()
|
||||
expect(mockBrushDrawing.handleDrawing).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should pan on left button + space drag', async () => {
|
||||
const tm = setup()
|
||||
mockKeyboard.isKeyDown.mockImplementation((k) => k === ' ')
|
||||
|
||||
await tm.handlePointerMove(pointerEvent({ buttons: 1 }))
|
||||
|
||||
expect(mockPanZoom.handlePanMove).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should ignore drawing for non-drawing tools', async () => {
|
||||
const tm = setup()
|
||||
mockStore.currentTool = Tools.MaskBucket
|
||||
|
||||
await tm.handlePointerMove(pointerEvent({ buttons: 1 }))
|
||||
|
||||
expect(mockBrushDrawing.handleDrawing).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should adjust brush on alt + right-drag while adjusting', async () => {
|
||||
const tm = setup()
|
||||
mockStore.isAdjustingBrush = true
|
||||
mockStore.currentTool = Tools.MaskPen
|
||||
|
||||
await tm.handlePointerMove(pointerEvent({ altKey: true, buttons: 2 }))
|
||||
|
||||
expect(mockBrushDrawing.handleBrushAdjustment).toHaveBeenCalled()
|
||||
expect(mockBrushDrawing.handleDrawing).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call handleDrawing on left or right drag for drawing tools', async () => {
|
||||
const tm = setup()
|
||||
mockStore.currentTool = Tools.MaskPen
|
||||
|
||||
await tm.handlePointerMove(pointerEvent({ buttons: 1 }))
|
||||
expect(mockBrushDrawing.handleDrawing).toHaveBeenCalledTimes(1)
|
||||
|
||||
await tm.handlePointerMove(pointerEvent({ buttons: 2 }))
|
||||
expect(mockBrushDrawing.handleDrawing).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handlePointerUp', () => {
|
||||
it('should reset panning and brush state', async () => {
|
||||
const tm = setup()
|
||||
mockStore.isPanning = true
|
||||
mockStore.brushVisible = false
|
||||
mockStore.isAdjustingBrush = true
|
||||
|
||||
await tm.handlePointerUp(pointerEvent({}))
|
||||
|
||||
expect(mockStore.isPanning).toBe(false)
|
||||
expect(mockStore.brushVisible).toBe(true)
|
||||
expect(mockStore.isAdjustingBrush).toBe(false)
|
||||
expect(mockBrushDrawing.drawEnd).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should remove pen pointer id when pointerType is "pen"', async () => {
|
||||
const tm = setup()
|
||||
|
||||
await tm.handlePointerUp(
|
||||
pointerEvent({ pointerType: 'pen', pointerId: 12 })
|
||||
)
|
||||
|
||||
expect(mockPanZoom.removePenPointerId).toHaveBeenCalledWith(12)
|
||||
})
|
||||
|
||||
it('should bail out before drawEnd for touch pointers', async () => {
|
||||
const tm = setup()
|
||||
|
||||
await tm.handlePointerUp(pointerEvent({ pointerType: 'touch' }))
|
||||
|
||||
expect(mockBrushDrawing.drawEnd).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -86,129 +86,6 @@ describe('useNodeDragAndDrop', () => {
|
||||
expect(isDragging).toBe(false)
|
||||
})
|
||||
|
||||
describe('claimEvent flag', () => {
|
||||
function createClaimableEvent(
|
||||
options: Parameters<typeof createDragEvent>[0]
|
||||
) {
|
||||
const event = createDragEvent(options)
|
||||
const preventDefault = vi.fn()
|
||||
const stopPropagation = vi.fn()
|
||||
Object.assign(event, { preventDefault, stopPropagation })
|
||||
return { event, preventDefault, stopPropagation }
|
||||
}
|
||||
|
||||
it('claims the event synchronously before awaiting onDrop for valid file drops', async () => {
|
||||
const { event, preventDefault, stopPropagation } = createClaimableEvent({
|
||||
files: [createFile('a.png')]
|
||||
})
|
||||
|
||||
const onDrop = vi.fn().mockImplementation(async () => {
|
||||
// By the time onDrop runs, the event must already be claimed —
|
||||
// claiming after the await would let document fallback handlers fire.
|
||||
expect(preventDefault).toHaveBeenCalledTimes(1)
|
||||
expect(stopPropagation).toHaveBeenCalledTimes(1)
|
||||
return []
|
||||
})
|
||||
|
||||
const node = createNode()
|
||||
useNodeDragAndDrop(node, { onDrop })
|
||||
|
||||
const result = await node.onDragDrop?.(event, true)
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(onDrop).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not claim the event when files are filtered out', async () => {
|
||||
const node = createNode()
|
||||
useNodeDragAndDrop(node, {
|
||||
onDrop: vi.fn().mockResolvedValue([]),
|
||||
fileFilter: (file) => file.type === 'image/png'
|
||||
})
|
||||
|
||||
const { event, preventDefault, stopPropagation } = createClaimableEvent({
|
||||
files: [createFile('a.jpg', 'image/jpeg')]
|
||||
})
|
||||
|
||||
const result = await node.onDragDrop?.(event, true)
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(preventDefault).not.toHaveBeenCalled()
|
||||
expect(stopPropagation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('claims the event for same-origin uri drops before fetching', async () => {
|
||||
const { event, preventDefault, stopPropagation } = createClaimableEvent({
|
||||
uri: `${location.origin}/api/file?filename=uri.png`,
|
||||
types: ['text/uri-list']
|
||||
})
|
||||
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async () => {
|
||||
expect(preventDefault).toHaveBeenCalledTimes(1)
|
||||
expect(stopPropagation).toHaveBeenCalledTimes(1)
|
||||
return fromAny<Response, unknown>({
|
||||
ok: true,
|
||||
blob: vi
|
||||
.fn()
|
||||
.mockResolvedValue(new Blob(['uri'], { type: 'image/png' }))
|
||||
})
|
||||
})
|
||||
|
||||
const node = createNode()
|
||||
useNodeDragAndDrop(node, { onDrop: vi.fn().mockResolvedValue([]) })
|
||||
|
||||
const result = await node.onDragDrop?.(event, true)
|
||||
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('does not claim the event for cross-origin uri drops', async () => {
|
||||
const node = createNode()
|
||||
useNodeDragAndDrop(node, { onDrop: vi.fn().mockResolvedValue([]) })
|
||||
|
||||
const { event, preventDefault, stopPropagation } = createClaimableEvent({
|
||||
uri: 'https://example.com/api/file?filename=uri.png',
|
||||
types: ['text/uri-list']
|
||||
})
|
||||
|
||||
const result = await node.onDragDrop?.(event, true)
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(preventDefault).not.toHaveBeenCalled()
|
||||
expect(stopPropagation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not claim the event when drop has no files and no uri', async () => {
|
||||
const node = createNode()
|
||||
useNodeDragAndDrop(node, { onDrop: vi.fn().mockResolvedValue([]) })
|
||||
|
||||
const { event, preventDefault, stopPropagation } = createClaimableEvent(
|
||||
{}
|
||||
)
|
||||
|
||||
const result = await node.onDragDrop?.(event, true)
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(preventDefault).not.toHaveBeenCalled()
|
||||
expect(stopPropagation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not claim the event when claimEvent is omitted', async () => {
|
||||
const node = createNode()
|
||||
useNodeDragAndDrop(node, { onDrop: vi.fn().mockResolvedValue([]) })
|
||||
|
||||
const { event, preventDefault, stopPropagation } = createClaimableEvent({
|
||||
files: [createFile('a.png')]
|
||||
})
|
||||
|
||||
const result = await node.onDragDrop?.(event)
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(preventDefault).not.toHaveBeenCalled()
|
||||
expect(stopPropagation).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('onDragDrop calls onDrop with filtered files', async () => {
|
||||
const onDrop = vi.fn().mockResolvedValue([])
|
||||
const node = createNode()
|
||||
|
||||
@@ -47,15 +47,11 @@ export const useNodeDragAndDrop = <T>(
|
||||
const installedDragOver = isDraggingFiles
|
||||
node.onDragOver = installedDragOver
|
||||
|
||||
const installedDragDrop = async function (e: DragEvent, claimEvent = false) {
|
||||
const installedDragDrop = async function (e: DragEvent) {
|
||||
if (!isDraggingValidFiles(e)) return false
|
||||
|
||||
const files = filterFiles(e.dataTransfer!.files)
|
||||
if (files.length) {
|
||||
if (claimEvent) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
await onDrop(files)
|
||||
return true
|
||||
}
|
||||
@@ -63,11 +59,6 @@ export const useNodeDragAndDrop = <T>(
|
||||
const uri = URL.parse(e?.dataTransfer?.getData('text/uri-list') ?? '')
|
||||
if (!uri || uri.origin !== location.origin) return false
|
||||
|
||||
if (claimEvent) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch(uri)
|
||||
const fileName = uri?.searchParams?.get('filename')
|
||||
|
||||
@@ -241,259 +241,6 @@ describe('Load3d', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('viewport wiring', () => {
|
||||
it('isActive ORs the activity flags through isLoad3dActive', () => {
|
||||
Object.assign(ctx.load3d, {
|
||||
STATUS_MOUSE_ON_NODE: false,
|
||||
STATUS_MOUSE_ON_SCENE: false,
|
||||
STATUS_MOUSE_ON_VIEWER: false,
|
||||
INITIAL_RENDER_DONE: true,
|
||||
animationManager: { isAnimationPlaying: false, dispose: vi.fn() },
|
||||
recordingManager: { getIsRecording: vi.fn(() => false) }
|
||||
})
|
||||
|
||||
expect(ctx.load3d.isActive()).toBe(false)
|
||||
;(ctx.load3d as { STATUS_MOUSE_ON_NODE: boolean }).STATUS_MOUSE_ON_NODE =
|
||||
true
|
||||
expect(ctx.load3d.isActive()).toBe(true)
|
||||
})
|
||||
|
||||
it('handleResize letterboxes the renderer when a target aspect ratio is set', () => {
|
||||
delete (ctx.load3d as { handleResize?: unknown }).handleResize
|
||||
|
||||
const parent = document.createElement('div')
|
||||
Object.defineProperty(parent, 'clientWidth', {
|
||||
value: 800,
|
||||
configurable: true
|
||||
})
|
||||
Object.defineProperty(parent, 'clientHeight', {
|
||||
value: 600,
|
||||
configurable: true
|
||||
})
|
||||
const canvas = document.createElement('canvas')
|
||||
parent.appendChild(canvas)
|
||||
|
||||
const setSize = vi.fn()
|
||||
const cameraResize = vi.fn()
|
||||
const sceneResize = vi.fn()
|
||||
|
||||
Object.assign(ctx.load3d, {
|
||||
renderer: { domElement: canvas, setSize },
|
||||
targetWidth: 400,
|
||||
targetHeight: 200,
|
||||
targetAspectRatio: 2,
|
||||
isViewerMode: false,
|
||||
cameraManager: { ...ctx.cameraManager, handleResize: cameraResize },
|
||||
sceneManager: { ...ctx.sceneManager, handleResize: sceneResize }
|
||||
})
|
||||
|
||||
ctx.load3d.handleResize()
|
||||
|
||||
// Container 800x600, target aspect 2:1 → letterboxed render area 800x400
|
||||
expect(setSize).toHaveBeenCalledWith(800, 600)
|
||||
expect(cameraResize).toHaveBeenCalledWith(800, 400)
|
||||
expect(sceneResize).toHaveBeenCalledWith(800, 400)
|
||||
})
|
||||
|
||||
it('renderMainScene applies the letterboxed viewport and feeds aspect to the camera', () => {
|
||||
const setViewport = vi.fn()
|
||||
const setScissor = vi.fn()
|
||||
const setScissorTest = vi.fn()
|
||||
const setClearColor = vi.fn()
|
||||
const clear = vi.fn()
|
||||
const render = vi.fn()
|
||||
const updateAspectRatio = vi.fn()
|
||||
const renderBackground = vi.fn()
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
Object.defineProperty(canvas, 'clientWidth', {
|
||||
value: 800,
|
||||
configurable: true
|
||||
})
|
||||
Object.defineProperty(canvas, 'clientHeight', {
|
||||
value: 600,
|
||||
configurable: true
|
||||
})
|
||||
const scene = {} as THREE.Scene
|
||||
|
||||
Object.assign(ctx.load3d, {
|
||||
renderer: {
|
||||
domElement: canvas,
|
||||
setViewport,
|
||||
setScissor,
|
||||
setScissorTest,
|
||||
setClearColor,
|
||||
clear,
|
||||
render
|
||||
},
|
||||
targetWidth: 400,
|
||||
targetHeight: 200,
|
||||
targetAspectRatio: 2,
|
||||
isViewerMode: false,
|
||||
cameraManager: { ...ctx.cameraManager, updateAspectRatio },
|
||||
sceneManager: { ...ctx.sceneManager, renderBackground, scene }
|
||||
})
|
||||
|
||||
ctx.load3d.renderMainScene()
|
||||
|
||||
expect(setViewport).toHaveBeenNthCalledWith(1, 0, 0, 800, 600)
|
||||
expect(setScissor).toHaveBeenNthCalledWith(1, 0, 0, 800, 600)
|
||||
expect(setViewport).toHaveBeenNthCalledWith(2, 0, 100, 800, 400)
|
||||
expect(setScissor).toHaveBeenNthCalledWith(2, 0, 100, 800, 400)
|
||||
expect(updateAspectRatio).toHaveBeenCalledWith(2)
|
||||
expect(setScissorTest).toHaveBeenCalledWith(true)
|
||||
expect(render).toHaveBeenCalledWith(scene, ctx.cameraManager.activeCamera)
|
||||
})
|
||||
|
||||
it('setBackgroundImage updates background size with letterbox dimensions when a texture is loaded', async () => {
|
||||
const updateBackgroundSize = vi.fn()
|
||||
const setBackgroundImage = vi.fn().mockResolvedValue(undefined)
|
||||
const canvas = document.createElement('canvas')
|
||||
Object.defineProperty(canvas, 'clientWidth', {
|
||||
value: 800,
|
||||
configurable: true
|
||||
})
|
||||
Object.defineProperty(canvas, 'clientHeight', {
|
||||
value: 600,
|
||||
configurable: true
|
||||
})
|
||||
|
||||
Object.assign(ctx.load3d, {
|
||||
renderer: { domElement: canvas },
|
||||
targetWidth: 400,
|
||||
targetHeight: 200,
|
||||
targetAspectRatio: 2,
|
||||
isViewerMode: false,
|
||||
sceneManager: {
|
||||
...ctx.sceneManager,
|
||||
setBackgroundImage,
|
||||
updateBackgroundSize,
|
||||
backgroundTexture: {},
|
||||
backgroundMesh: {}
|
||||
}
|
||||
})
|
||||
|
||||
await ctx.load3d.setBackgroundImage('test.png')
|
||||
|
||||
expect(setBackgroundImage).toHaveBeenCalledWith('test.png')
|
||||
// Container 800x600, target aspect 2:1 → letterbox render area 800x400
|
||||
const args = updateBackgroundSize.mock.calls[0]
|
||||
expect(args[2]).toBe(800)
|
||||
expect(args[3]).toBe(400)
|
||||
})
|
||||
})
|
||||
|
||||
describe('render loop wiring', () => {
|
||||
it('startAnimation registers a render loop whose tick body runs the per-frame managers when active', () => {
|
||||
const animationUpdate = vi.fn()
|
||||
const viewHelperUpdate = vi.fn()
|
||||
const viewHelperRender = vi.fn()
|
||||
const controlsUpdate = vi.fn()
|
||||
const renderMainScene = vi.fn()
|
||||
const resetViewport = vi.fn()
|
||||
|
||||
Object.assign(ctx.load3d, {
|
||||
STATUS_MOUSE_ON_NODE: true,
|
||||
STATUS_MOUSE_ON_SCENE: false,
|
||||
STATUS_MOUSE_ON_VIEWER: false,
|
||||
INITIAL_RENDER_DONE: false,
|
||||
clock: new THREE.Clock(),
|
||||
animationManager: {
|
||||
update: animationUpdate,
|
||||
isAnimationPlaying: false,
|
||||
dispose: vi.fn()
|
||||
},
|
||||
viewHelperManager: {
|
||||
update: viewHelperUpdate,
|
||||
viewHelper: { render: viewHelperRender }
|
||||
},
|
||||
controlsManager: { update: controlsUpdate },
|
||||
recordingManager: { getIsRecording: vi.fn(() => false) },
|
||||
renderMainScene,
|
||||
resetViewport,
|
||||
renderer: {}
|
||||
})
|
||||
|
||||
;(ctx.load3d as unknown as { startAnimation(): void }).startAnimation()
|
||||
|
||||
const loop = (ctx.load3d as unknown as { renderLoop: { stop(): void } })
|
||||
.renderLoop
|
||||
expect(loop).not.toBeNull()
|
||||
expect(typeof loop.stop).toBe('function')
|
||||
|
||||
// The first loop() ran synchronously; isActive() returned true
|
||||
// (STATUS_MOUSE_ON_NODE), so the tick body executed once.
|
||||
expect(animationUpdate).toHaveBeenCalledOnce()
|
||||
expect(viewHelperUpdate).toHaveBeenCalledOnce()
|
||||
expect(controlsUpdate).toHaveBeenCalledOnce()
|
||||
expect(renderMainScene).toHaveBeenCalledOnce()
|
||||
expect(resetViewport).toHaveBeenCalledOnce()
|
||||
expect(viewHelperRender).toHaveBeenCalledOnce()
|
||||
|
||||
// Cancel the queued rAF so the test doesn't leak frames.
|
||||
loop.stop()
|
||||
})
|
||||
|
||||
it('remove() stops the active render loop and clears the handle', () => {
|
||||
const stop = vi.fn()
|
||||
const canvas = document.createElement('canvas')
|
||||
|
||||
Object.assign(ctx.load3d, {
|
||||
renderLoop: { stop },
|
||||
resizeObserver: null,
|
||||
contextMenuAbortController: null,
|
||||
renderer: {
|
||||
forceContextLoss: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
domElement: canvas
|
||||
},
|
||||
sceneManager: { ...ctx.sceneManager, dispose: vi.fn() },
|
||||
cameraManager: { ...ctx.cameraManager, dispose: vi.fn() },
|
||||
controlsManager: { ...ctx.controlsManager, dispose: vi.fn() },
|
||||
lightingManager: { dispose: vi.fn() },
|
||||
hdriManager: { dispose: vi.fn() },
|
||||
viewHelperManager: { dispose: vi.fn() },
|
||||
loaderManager: { dispose: vi.fn() },
|
||||
modelManager: { ...ctx.modelManager, dispose: vi.fn() },
|
||||
recordingManager: { dispose: vi.fn() },
|
||||
animationManager: { ...ctx.animationManager, dispose: vi.fn() },
|
||||
gizmoManager: { ...ctx.gizmo, dispose: vi.fn() }
|
||||
})
|
||||
|
||||
ctx.load3d.remove()
|
||||
|
||||
expect(stop).toHaveBeenCalledOnce()
|
||||
expect(
|
||||
(ctx.load3d as unknown as { renderLoop: unknown }).renderLoop
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('adapter-driven kind queries', () => {
|
||||
function makeWithAdapter(kind: 'mesh' | 'pointCloud' | 'splat' | null) {
|
||||
const adapter = kind === null ? null : { kind }
|
||||
Object.assign(ctx.load3d, {
|
||||
loaderManager: { getCurrentAdapter: vi.fn(() => adapter) }
|
||||
})
|
||||
}
|
||||
|
||||
it('isSplatModel is true only when the current adapter kind is "splat"', () => {
|
||||
makeWithAdapter('splat')
|
||||
expect(ctx.load3d.isSplatModel()).toBe(true)
|
||||
makeWithAdapter('mesh')
|
||||
expect(ctx.load3d.isSplatModel()).toBe(false)
|
||||
makeWithAdapter(null)
|
||||
expect(ctx.load3d.isSplatModel()).toBe(false)
|
||||
})
|
||||
|
||||
it('isPlyModel is true only when the current adapter kind is "pointCloud"', () => {
|
||||
makeWithAdapter('pointCloud')
|
||||
expect(ctx.load3d.isPlyModel()).toBe(true)
|
||||
makeWithAdapter('mesh')
|
||||
expect(ctx.load3d.isPlyModel()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('captureScene', () => {
|
||||
it('hides the gizmo helper during capture and restores it after success', async () => {
|
||||
const captureResult = { scene: 'a', mask: 'b', normal: 'c' }
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import * as THREE from 'three'
|
||||
|
||||
import { exceedsClickThreshold } from '@/composables/useClickDragGuard'
|
||||
|
||||
import { AnimationManager } from './AnimationManager'
|
||||
import { CameraManager } from './CameraManager'
|
||||
import { ControlsManager } from './ControlsManager'
|
||||
@@ -22,10 +24,6 @@ import type {
|
||||
MaterialMode,
|
||||
UpDirection
|
||||
} from './interfaces'
|
||||
import { attachContextMenuGuard } from './load3dContextMenuGuard'
|
||||
import type { RenderLoopHandle } from './load3dRenderLoop'
|
||||
import { startRenderLoop } from './load3dRenderLoop'
|
||||
import { computeLetterboxedViewport, isLoad3dActive } from './load3dViewport'
|
||||
|
||||
function positionThumbnailCamera(
|
||||
camera: THREE.PerspectiveCamera,
|
||||
@@ -49,7 +47,7 @@ function positionThumbnailCamera(
|
||||
class Load3d {
|
||||
renderer: THREE.WebGLRenderer
|
||||
protected clock: THREE.Clock
|
||||
private renderLoop: RenderLoopHandle | null = null
|
||||
protected animationFrameId: number | null = null
|
||||
private loadingPromise: Promise<void> | null = null
|
||||
private onContextMenuCallback?: (event: MouseEvent) => void
|
||||
private getDimensionsCallback?: () => { width: number; height: number } | null
|
||||
@@ -77,7 +75,10 @@ class Load3d {
|
||||
targetAspectRatio: number = 1
|
||||
isViewerMode: boolean = false
|
||||
|
||||
private disposeContextMenuGuard: (() => void) | null = null
|
||||
private rightMouseStart: { x: number; y: number } = { x: 0, y: 0 }
|
||||
private rightMouseMoved: boolean = false
|
||||
private readonly dragThreshold: number = 5
|
||||
private contextMenuAbortController: AbortController | null = null
|
||||
private resizeObserver: ResizeObserver | null = null
|
||||
|
||||
constructor(container: Element | HTMLElement, options: Load3DOptions = {}) {
|
||||
@@ -213,12 +214,69 @@ class Load3d {
|
||||
this.resizeObserver.observe(container)
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize context menu on the Three.js canvas
|
||||
* Detects right-click vs right-drag to show menu only on click
|
||||
*/
|
||||
private initContextMenu(): void {
|
||||
this.disposeContextMenuGuard = attachContextMenuGuard(
|
||||
this.renderer.domElement,
|
||||
(event) => this.onContextMenuCallback?.(event),
|
||||
{ isDisabled: () => this.isViewerMode }
|
||||
)
|
||||
const canvas = this.renderer.domElement
|
||||
|
||||
this.contextMenuAbortController = new AbortController()
|
||||
const { signal } = this.contextMenuAbortController
|
||||
|
||||
const mousedownHandler = (e: MouseEvent) => {
|
||||
if (e.button === 2) {
|
||||
this.rightMouseStart = { x: e.clientX, y: e.clientY }
|
||||
this.rightMouseMoved = false
|
||||
}
|
||||
}
|
||||
|
||||
const mousemoveHandler = (e: MouseEvent) => {
|
||||
if (e.buttons === 2) {
|
||||
if (
|
||||
exceedsClickThreshold(
|
||||
this.rightMouseStart,
|
||||
{ x: e.clientX, y: e.clientY },
|
||||
this.dragThreshold
|
||||
)
|
||||
) {
|
||||
this.rightMouseMoved = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const contextmenuHandler = (e: MouseEvent) => {
|
||||
if (this.isViewerMode) return
|
||||
|
||||
const wasDragging =
|
||||
this.rightMouseMoved ||
|
||||
exceedsClickThreshold(
|
||||
this.rightMouseStart,
|
||||
{ x: e.clientX, y: e.clientY },
|
||||
this.dragThreshold
|
||||
)
|
||||
|
||||
this.rightMouseMoved = false
|
||||
|
||||
if (wasDragging) {
|
||||
return
|
||||
}
|
||||
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
this.showNodeContextMenu(e)
|
||||
}
|
||||
|
||||
canvas.addEventListener('mousedown', mousedownHandler, { signal })
|
||||
canvas.addEventListener('mousemove', mousemoveHandler, { signal })
|
||||
canvas.addEventListener('contextmenu', contextmenuHandler, { signal })
|
||||
}
|
||||
|
||||
private showNodeContextMenu(event: MouseEvent): void {
|
||||
if (this.onContextMenuCallback) {
|
||||
this.onContextMenuCallback(event)
|
||||
}
|
||||
}
|
||||
|
||||
getEventManager(): EventManager {
|
||||
@@ -296,10 +354,22 @@ class Load3d {
|
||||
}
|
||||
|
||||
if (this.shouldMaintainAspectRatio()) {
|
||||
const { offsetX, offsetY, width, height } = computeLetterboxedViewport(
|
||||
{ width: containerWidth, height: containerHeight },
|
||||
this.targetAspectRatio
|
||||
)
|
||||
const containerAspectRatio = containerWidth / containerHeight
|
||||
|
||||
let renderWidth: number
|
||||
let renderHeight: number
|
||||
let offsetX: number = 0
|
||||
let offsetY: number = 0
|
||||
|
||||
if (containerAspectRatio > this.targetAspectRatio) {
|
||||
renderHeight = containerHeight
|
||||
renderWidth = renderHeight * this.targetAspectRatio
|
||||
offsetX = (containerWidth - renderWidth) / 2
|
||||
} else {
|
||||
renderWidth = containerWidth
|
||||
renderHeight = renderWidth / this.targetAspectRatio
|
||||
offsetY = (containerHeight - renderHeight) / 2
|
||||
}
|
||||
|
||||
this.renderer.setViewport(0, 0, containerWidth, containerHeight)
|
||||
this.renderer.setScissor(0, 0, containerWidth, containerHeight)
|
||||
@@ -307,10 +377,11 @@ class Load3d {
|
||||
this.renderer.setClearColor(0x0a0a0a)
|
||||
this.renderer.clear()
|
||||
|
||||
this.renderer.setViewport(offsetX, offsetY, width, height)
|
||||
this.renderer.setScissor(offsetX, offsetY, width, height)
|
||||
this.renderer.setViewport(offsetX, offsetY, renderWidth, renderHeight)
|
||||
this.renderer.setScissor(offsetX, offsetY, renderWidth, renderHeight)
|
||||
|
||||
this.cameraManager.updateAspectRatio(width / height)
|
||||
const renderAspectRatio = renderWidth / renderHeight
|
||||
this.cameraManager.updateAspectRatio(renderAspectRatio)
|
||||
} else {
|
||||
// No aspect ratio constraint: fill the entire container
|
||||
this.renderer.setViewport(0, 0, containerWidth, containerHeight)
|
||||
@@ -351,23 +422,28 @@ class Load3d {
|
||||
}
|
||||
|
||||
private startAnimation(): void {
|
||||
this.renderLoop = startRenderLoop({
|
||||
tick: () => {
|
||||
const delta = this.clock.getDelta()
|
||||
this.animationManager.update(delta)
|
||||
this.viewHelperManager.update(delta)
|
||||
this.controlsManager.update()
|
||||
const animate = () => {
|
||||
this.animationFrameId = requestAnimationFrame(animate)
|
||||
|
||||
this.renderMainScene()
|
||||
if (!this.isActive()) {
|
||||
return
|
||||
}
|
||||
|
||||
this.resetViewport()
|
||||
const delta = this.clock.getDelta()
|
||||
this.animationManager.update(delta)
|
||||
this.viewHelperManager.update(delta)
|
||||
this.controlsManager.update()
|
||||
|
||||
if (this.viewHelperManager.viewHelper.render) {
|
||||
this.viewHelperManager.viewHelper.render(this.renderer)
|
||||
}
|
||||
},
|
||||
isActive: () => this.isActive()
|
||||
})
|
||||
this.renderMainScene()
|
||||
|
||||
this.resetViewport()
|
||||
|
||||
if (this.viewHelperManager.viewHelper.render) {
|
||||
this.viewHelperManager.viewHelper.render(this.renderer)
|
||||
}
|
||||
}
|
||||
|
||||
animate()
|
||||
}
|
||||
|
||||
updateStatusMouseOnNode(onNode: boolean): void {
|
||||
@@ -383,14 +459,14 @@ class Load3d {
|
||||
}
|
||||
|
||||
isActive(): boolean {
|
||||
return isLoad3dActive({
|
||||
mouseOnNode: this.STATUS_MOUSE_ON_NODE,
|
||||
mouseOnScene: this.STATUS_MOUSE_ON_SCENE,
|
||||
mouseOnViewer: this.STATUS_MOUSE_ON_VIEWER,
|
||||
recording: this.isRecording(),
|
||||
initialRenderDone: this.INITIAL_RENDER_DONE,
|
||||
animationPlaying: this.animationManager.isAnimationPlaying
|
||||
})
|
||||
return (
|
||||
this.STATUS_MOUSE_ON_NODE ||
|
||||
this.STATUS_MOUSE_ON_SCENE ||
|
||||
this.STATUS_MOUSE_ON_VIEWER ||
|
||||
this.isRecording() ||
|
||||
!this.INITIAL_RENDER_DONE ||
|
||||
this.animationManager.isAnimationPlaying
|
||||
)
|
||||
}
|
||||
|
||||
async exportModel(format: string): Promise<void> {
|
||||
@@ -451,16 +527,24 @@ class Load3d {
|
||||
const containerHeight = this.renderer.domElement.clientHeight
|
||||
|
||||
if (this.shouldMaintainAspectRatio()) {
|
||||
const { width, height } = computeLetterboxedViewport(
|
||||
{ width: containerWidth, height: containerHeight },
|
||||
this.targetAspectRatio
|
||||
)
|
||||
const containerAspectRatio = containerWidth / containerHeight
|
||||
|
||||
let renderWidth: number
|
||||
let renderHeight: number
|
||||
|
||||
if (containerAspectRatio > this.targetAspectRatio) {
|
||||
renderHeight = containerHeight
|
||||
renderWidth = renderHeight * this.targetAspectRatio
|
||||
} else {
|
||||
renderWidth = containerWidth
|
||||
renderHeight = renderWidth / this.targetAspectRatio
|
||||
}
|
||||
|
||||
this.sceneManager.updateBackgroundSize(
|
||||
this.sceneManager.backgroundTexture,
|
||||
this.sceneManager.backgroundMesh,
|
||||
width,
|
||||
height
|
||||
renderWidth,
|
||||
renderHeight
|
||||
)
|
||||
} else {
|
||||
// No aspect ratio constraints: fill container
|
||||
@@ -567,11 +651,11 @@ class Load3d {
|
||||
}
|
||||
|
||||
isSplatModel(): boolean {
|
||||
return this.loaderManager.getCurrentAdapter()?.kind === 'splat'
|
||||
return this.modelManager.containsSplatMesh()
|
||||
}
|
||||
|
||||
isPlyModel(): boolean {
|
||||
return this.loaderManager.getCurrentAdapter()?.kind === 'pointCloud'
|
||||
return this.modelManager.originalModel instanceof THREE.BufferGeometry
|
||||
}
|
||||
|
||||
clearModel(): void {
|
||||
@@ -658,14 +742,21 @@ class Load3d {
|
||||
}
|
||||
|
||||
if (this.shouldMaintainAspectRatio()) {
|
||||
const { width, height } = computeLetterboxedViewport(
|
||||
{ width: containerWidth, height: containerHeight },
|
||||
this.targetAspectRatio
|
||||
)
|
||||
const containerAspectRatio = containerWidth / containerHeight
|
||||
let renderWidth: number
|
||||
let renderHeight: number
|
||||
|
||||
if (containerAspectRatio > this.targetAspectRatio) {
|
||||
renderHeight = containerHeight
|
||||
renderWidth = renderHeight * this.targetAspectRatio
|
||||
} else {
|
||||
renderWidth = containerWidth
|
||||
renderHeight = renderWidth / this.targetAspectRatio
|
||||
}
|
||||
|
||||
this.renderer.setSize(containerWidth, containerHeight)
|
||||
this.cameraManager.handleResize(width, height)
|
||||
this.sceneManager.handleResize(width, height)
|
||||
this.cameraManager.handleResize(renderWidth, renderHeight)
|
||||
this.sceneManager.handleResize(renderWidth, renderHeight)
|
||||
} else {
|
||||
// No aspect ratio constraint: use container dimensions directly
|
||||
this.renderer.setSize(containerWidth, containerHeight)
|
||||
@@ -854,8 +945,10 @@ class Load3d {
|
||||
this.resizeObserver = null
|
||||
}
|
||||
|
||||
this.disposeContextMenuGuard?.()
|
||||
this.disposeContextMenuGuard = null
|
||||
if (this.contextMenuAbortController) {
|
||||
this.contextMenuAbortController.abort()
|
||||
this.contextMenuAbortController = null
|
||||
}
|
||||
|
||||
this.renderer.forceContextLoss()
|
||||
const canvas = this.renderer.domElement
|
||||
@@ -865,8 +958,9 @@ class Load3d {
|
||||
})
|
||||
canvas.dispatchEvent(event)
|
||||
|
||||
this.renderLoop?.stop()
|
||||
this.renderLoop = null
|
||||
if (this.animationFrameId !== null) {
|
||||
cancelAnimationFrame(this.animationFrameId)
|
||||
}
|
||||
|
||||
this.sceneManager.dispose()
|
||||
this.cameraManager.dispose()
|
||||
|
||||
@@ -1,530 +0,0 @@
|
||||
import * as THREE from 'three'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type {
|
||||
EventManagerInterface,
|
||||
MaterialMode,
|
||||
ModelManagerInterface
|
||||
} from './interfaces'
|
||||
import { LoaderManager } from './LoaderManager'
|
||||
import type { ModelAdapter, ModelLoadContext } from './ModelAdapter'
|
||||
|
||||
function makeEventManagerStub() {
|
||||
return {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
emitEvent: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
type ModelManagerStub = {
|
||||
clearModel: ReturnType<typeof vi.fn>
|
||||
setupModel: ReturnType<typeof vi.fn>
|
||||
setOriginalModel: ReturnType<typeof vi.fn>
|
||||
originalMaterials: WeakMap<THREE.Mesh, THREE.Material | THREE.Material[]>
|
||||
standardMaterial: THREE.MeshStandardMaterial
|
||||
materialMode: MaterialMode
|
||||
originalFileName: string | null
|
||||
originalURL: string | null
|
||||
}
|
||||
|
||||
function makeModelManagerStub(): ModelManagerStub {
|
||||
return {
|
||||
clearModel: vi.fn(),
|
||||
setupModel: vi.fn().mockResolvedValue(undefined),
|
||||
setOriginalModel: vi.fn(),
|
||||
originalMaterials: new WeakMap(),
|
||||
standardMaterial: new THREE.MeshStandardMaterial(),
|
||||
materialMode: 'original',
|
||||
originalFileName: 'model',
|
||||
originalURL: null
|
||||
}
|
||||
}
|
||||
|
||||
const { meshLoad, splatLoad, pointCloudLoad, getPLYEngineMock, addAlert } =
|
||||
vi.hoisted(() => ({
|
||||
meshLoad: vi.fn(),
|
||||
splatLoad: vi.fn(),
|
||||
pointCloudLoad: vi.fn(),
|
||||
getPLYEngineMock: vi.fn<() => string>(),
|
||||
addAlert: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./MeshModelAdapter', () => ({
|
||||
MeshModelAdapter: class {
|
||||
readonly kind = 'mesh' as const
|
||||
readonly extensions = ['stl', 'fbx', 'obj', 'gltf', 'glb'] as const
|
||||
readonly capabilities = {}
|
||||
load = meshLoad
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('./PointCloudModelAdapter', () => ({
|
||||
PointCloudModelAdapter: class {
|
||||
readonly kind = 'pointCloud' as const
|
||||
readonly extensions = ['ply'] as const
|
||||
readonly capabilities = {}
|
||||
load = pointCloudLoad
|
||||
},
|
||||
getPLYEngine: () => getPLYEngineMock()
|
||||
}))
|
||||
|
||||
vi.mock('./SplatModelAdapter', () => ({
|
||||
SplatModelAdapter: class {
|
||||
readonly kind = 'splat' as const
|
||||
readonly extensions = ['spz', 'splat', 'ksplat'] as const
|
||||
readonly capabilities = {}
|
||||
load = splatLoad
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string) => key
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: () => ({ addAlert })
|
||||
}))
|
||||
|
||||
type LoaderManagerInternals = {
|
||||
pickAdapter(extension: string): ModelAdapter | null
|
||||
}
|
||||
|
||||
function makeLoaderManager() {
|
||||
const modelManager = makeModelManagerStub()
|
||||
const eventManager = makeEventManagerStub()
|
||||
const lm = new LoaderManager(
|
||||
modelManager as unknown as ConstructorParameters<typeof LoaderManager>[0],
|
||||
eventManager
|
||||
)
|
||||
const internals = lm as unknown as LoaderManagerInternals
|
||||
return {
|
||||
lm,
|
||||
modelManager,
|
||||
eventManager,
|
||||
pick: internals.pickAdapter.bind(lm)
|
||||
}
|
||||
}
|
||||
|
||||
describe('LoaderManager', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
getPLYEngineMock.mockReturnValue('three')
|
||||
meshLoad.mockResolvedValue(null)
|
||||
splatLoad.mockResolvedValue(null)
|
||||
pointCloudLoad.mockResolvedValue(null)
|
||||
})
|
||||
|
||||
describe('getCurrentAdapter', () => {
|
||||
it('returns null before any model loads', () => {
|
||||
const { lm } = makeLoaderManager()
|
||||
expect(lm.getCurrentAdapter()).toBeNull()
|
||||
})
|
||||
|
||||
it('exposes the picked adapter after a successful load', async () => {
|
||||
const { lm } = makeLoaderManager()
|
||||
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
|
||||
|
||||
await lm.loadModel('api/view?filename=cube.glb')
|
||||
|
||||
expect(lm.getCurrentAdapter()?.kind).toBe('mesh')
|
||||
})
|
||||
|
||||
it('resets to null at the start of a new load', async () => {
|
||||
const { lm } = makeLoaderManager()
|
||||
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
|
||||
|
||||
await lm.loadModel('api/view?filename=cube.glb')
|
||||
expect(lm.getCurrentAdapter()?.kind).toBe('mesh')
|
||||
|
||||
await lm.loadModel('api/view?filename=cube.xyz')
|
||||
expect(lm.getCurrentAdapter()).toBeNull()
|
||||
})
|
||||
|
||||
it('stays null when the adapter rejects', async () => {
|
||||
const { lm } = makeLoaderManager()
|
||||
// Seed with a previously-successful mesh load so we can prove a later
|
||||
// failed splat load does not leave the splat adapter published.
|
||||
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
|
||||
await lm.loadModel('api/view?filename=cube.glb')
|
||||
expect(lm.getCurrentAdapter()?.kind).toBe('mesh')
|
||||
|
||||
splatLoad.mockRejectedValueOnce(new Error('boom'))
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
await lm.loadModel('api/view?filename=scan.splat')
|
||||
|
||||
expect(lm.getCurrentAdapter()).toBeNull()
|
||||
})
|
||||
|
||||
it('stays null when the adapter resolves null (parse failure)', async () => {
|
||||
const { lm } = makeLoaderManager()
|
||||
pointCloudLoad.mockResolvedValueOnce(null)
|
||||
|
||||
await lm.loadModel('api/view?filename=scan.ply')
|
||||
|
||||
expect(lm.getCurrentAdapter()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadModel ordering', () => {
|
||||
it('keeps the old adapter current while clearModel runs (so future dispose hooks see it)', async () => {
|
||||
const oldAdapter = {
|
||||
kind: 'splat' as const,
|
||||
extensions: ['splat'] as const,
|
||||
capabilities: {
|
||||
fitToViewer: false,
|
||||
requiresMaterialRebuild: false,
|
||||
gizmoTransform: false,
|
||||
lighting: false,
|
||||
exportable: false,
|
||||
materialModes: [],
|
||||
fitTargetSize: 5
|
||||
},
|
||||
load: vi.fn().mockResolvedValue(null)
|
||||
} satisfies ModelAdapter
|
||||
|
||||
const modelManager = {
|
||||
originalMaterials: new WeakMap(),
|
||||
clearModel: vi.fn(),
|
||||
setupModel: vi.fn()
|
||||
} as unknown as ModelManagerInterface
|
||||
const eventManager: EventManagerInterface = {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
emitEvent: vi.fn()
|
||||
}
|
||||
|
||||
let adapterDuringClear: ModelAdapter | null | undefined
|
||||
const lm = new LoaderManager(modelManager, eventManager, [oldAdapter])
|
||||
// Prime the loader with an active adapter, then trigger a new load.
|
||||
;(lm as unknown as { _currentAdapter: ModelAdapter })._currentAdapter =
|
||||
oldAdapter
|
||||
;(modelManager.clearModel as ReturnType<typeof vi.fn>).mockImplementation(
|
||||
() => {
|
||||
adapterDuringClear = lm.getCurrentAdapter()
|
||||
}
|
||||
)
|
||||
|
||||
await lm.loadModel(
|
||||
'api/view?type=input&subfolder=&filename=a.splat',
|
||||
'a.splat'
|
||||
)
|
||||
|
||||
expect(adapterDuringClear).toBe(oldAdapter)
|
||||
})
|
||||
})
|
||||
|
||||
describe('pickAdapter', () => {
|
||||
it.each(['stl', 'fbx', 'obj', 'gltf', 'glb'])(
|
||||
'routes %s to the mesh adapter',
|
||||
(ext) => {
|
||||
const { pick } = makeLoaderManager()
|
||||
expect(pick(ext)?.kind).toBe('mesh')
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['spz', 'splat', 'ksplat'])(
|
||||
'routes %s to the splat adapter',
|
||||
(ext) => {
|
||||
const { pick } = makeLoaderManager()
|
||||
expect(pick(ext)?.kind).toBe('splat')
|
||||
}
|
||||
)
|
||||
|
||||
it('routes .ply to the point-cloud adapter for the default three engine', () => {
|
||||
getPLYEngineMock.mockReturnValue('three')
|
||||
const { pick } = makeLoaderManager()
|
||||
expect(pick('ply')?.kind).toBe('pointCloud')
|
||||
})
|
||||
|
||||
it('routes .ply to the point-cloud adapter for the fastply engine', () => {
|
||||
getPLYEngineMock.mockReturnValue('fastply')
|
||||
const { pick } = makeLoaderManager()
|
||||
expect(pick('ply')?.kind).toBe('pointCloud')
|
||||
})
|
||||
|
||||
it('routes .ply to the splat adapter when the engine setting is sparkjs', () => {
|
||||
getPLYEngineMock.mockReturnValue('sparkjs')
|
||||
const { pick } = makeLoaderManager()
|
||||
expect(pick('ply')?.kind).toBe('splat')
|
||||
})
|
||||
|
||||
it('returns null for unknown extensions', () => {
|
||||
const { pick } = makeLoaderManager()
|
||||
expect(pick('xyz')).toBeNull()
|
||||
expect(pick('')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadModel', () => {
|
||||
it('emits modelLoadingStart and records originalURL before dispatching', async () => {
|
||||
const { lm, eventManager, modelManager } = makeLoaderManager()
|
||||
|
||||
await lm.loadModel('api/view?filename=cube.glb')
|
||||
|
||||
expect(eventManager.emitEvent).toHaveBeenCalledWith(
|
||||
'modelLoadingStart',
|
||||
null
|
||||
)
|
||||
expect(modelManager.originalURL).toBe('api/view?filename=cube.glb')
|
||||
})
|
||||
|
||||
it('clears any existing model before routing to the adapter', async () => {
|
||||
const { lm, modelManager } = makeLoaderManager()
|
||||
const order: string[] = []
|
||||
modelManager.clearModel.mockImplementation(() => order.push('clear'))
|
||||
meshLoad.mockImplementationOnce(async () => {
|
||||
order.push('load')
|
||||
return null
|
||||
})
|
||||
|
||||
await lm.loadModel('api/view?filename=cube.glb')
|
||||
|
||||
expect(order).toEqual(['clear', 'load'])
|
||||
})
|
||||
|
||||
it('derives originalFileName from an explicit originalFileName argument', async () => {
|
||||
const { lm, modelManager } = makeLoaderManager()
|
||||
|
||||
await lm.loadModel('api/view?filename=ignored.glb', 'uploads/my-cube.glb')
|
||||
|
||||
expect(modelManager.originalFileName).toBe('my-cube')
|
||||
})
|
||||
|
||||
it('derives originalFileName from the URL filename param when no override is given', async () => {
|
||||
const { lm, modelManager } = makeLoaderManager()
|
||||
|
||||
await lm.loadModel('api/view?filename=cube.glb')
|
||||
|
||||
expect(modelManager.originalFileName).toBe('cube')
|
||||
})
|
||||
|
||||
it('falls back to "model" when the URL has no filename param', async () => {
|
||||
const { lm, modelManager } = makeLoaderManager()
|
||||
|
||||
await lm.loadModel('api/view?other=1')
|
||||
|
||||
expect(modelManager.originalFileName).toBe('model')
|
||||
})
|
||||
|
||||
it('alerts when the file extension cannot be determined', async () => {
|
||||
const { lm, modelManager } = makeLoaderManager()
|
||||
|
||||
await lm.loadModel('api/view?other=1')
|
||||
|
||||
expect(addAlert).toHaveBeenCalledWith(
|
||||
'toastMessages.couldNotDetermineFileType'
|
||||
)
|
||||
expect(modelManager.setupModel).not.toHaveBeenCalled()
|
||||
expect(meshLoad).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('passes setupModel the object returned by the adapter', async () => {
|
||||
const { lm, modelManager } = makeLoaderManager()
|
||||
const loaded = new THREE.Object3D()
|
||||
meshLoad.mockResolvedValueOnce(loaded)
|
||||
|
||||
await lm.loadModel('api/view?filename=cube.glb')
|
||||
|
||||
expect(modelManager.setupModel).toHaveBeenCalledWith(loaded)
|
||||
})
|
||||
|
||||
it('skips setupModel when the adapter returns null', async () => {
|
||||
const { lm, modelManager } = makeLoaderManager()
|
||||
meshLoad.mockResolvedValueOnce(null)
|
||||
|
||||
await lm.loadModel('api/view?filename=cube.glb')
|
||||
|
||||
expect(modelManager.setupModel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('emits modelLoadingEnd when the load completes', async () => {
|
||||
const { lm, eventManager } = makeLoaderManager()
|
||||
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
|
||||
|
||||
await lm.loadModel('api/view?filename=cube.glb')
|
||||
|
||||
expect(eventManager.emitEvent).toHaveBeenCalledWith(
|
||||
'modelLoadingEnd',
|
||||
null
|
||||
)
|
||||
})
|
||||
|
||||
it('forwards a decoded path and filename to the adapter', async () => {
|
||||
const { lm } = makeLoaderManager()
|
||||
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
|
||||
|
||||
await lm.loadModel(
|
||||
'api/view?type=output&subfolder=nested%2Fdir&filename=cube.glb'
|
||||
)
|
||||
|
||||
expect(meshLoad).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
setOriginalModel: expect.any(Function),
|
||||
registerOriginalMaterial: expect.any(Function)
|
||||
}),
|
||||
'api/view?type=output&subfolder=nested%2Fdir&filename=',
|
||||
'cube.glb'
|
||||
)
|
||||
})
|
||||
|
||||
it('defaults the path to type=input when no type param is given', async () => {
|
||||
const { lm } = makeLoaderManager()
|
||||
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
|
||||
|
||||
await lm.loadModel('api/view?filename=cube.glb')
|
||||
|
||||
expect(meshLoad).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'api/view?type=input&subfolder=&filename=',
|
||||
'cube.glb'
|
||||
)
|
||||
})
|
||||
|
||||
it('routes .ply through the splat adapter when the engine setting is sparkjs', async () => {
|
||||
getPLYEngineMock.mockReturnValue('sparkjs')
|
||||
const { lm } = makeLoaderManager()
|
||||
splatLoad.mockResolvedValueOnce(new THREE.Object3D())
|
||||
|
||||
await lm.loadModel('api/view?filename=scan.ply')
|
||||
|
||||
expect(splatLoad).toHaveBeenCalled()
|
||||
expect(pointCloudLoad).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handles adapter errors by alerting and still emitting modelLoadingEnd', async () => {
|
||||
const { lm, eventManager } = makeLoaderManager()
|
||||
const err = new Error('boom')
|
||||
meshLoad.mockRejectedValueOnce(err)
|
||||
const consoleError = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
await lm.loadModel('api/view?filename=cube.glb')
|
||||
|
||||
expect(eventManager.emitEvent).toHaveBeenCalledWith(
|
||||
'modelLoadingEnd',
|
||||
null
|
||||
)
|
||||
expect(addAlert).toHaveBeenCalledWith('toastMessages.errorLoadingModel')
|
||||
expect(consoleError).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('discards the result of a stale load when a newer one has started', async () => {
|
||||
const { lm, modelManager, eventManager } = makeLoaderManager()
|
||||
|
||||
let resolveFirst!: (value: THREE.Object3D) => void
|
||||
const firstLoad = new Promise<THREE.Object3D>((r) => {
|
||||
resolveFirst = r
|
||||
})
|
||||
const firstModel = new THREE.Object3D()
|
||||
firstModel.name = 'first'
|
||||
const secondModel = new THREE.Object3D()
|
||||
secondModel.name = 'second'
|
||||
|
||||
meshLoad
|
||||
.mockImplementationOnce(() => firstLoad)
|
||||
.mockResolvedValueOnce(secondModel)
|
||||
|
||||
const firstPromise = lm.loadModel('api/view?filename=first.glb')
|
||||
const secondPromise = lm.loadModel('api/view?filename=second.glb')
|
||||
|
||||
resolveFirst(firstModel)
|
||||
|
||||
await Promise.all([firstPromise, secondPromise])
|
||||
|
||||
expect(modelManager.setupModel).toHaveBeenCalledTimes(1)
|
||||
expect(modelManager.setupModel).toHaveBeenCalledWith(secondModel)
|
||||
|
||||
const endEmits = eventManager.emitEvent.mock.calls.filter(
|
||||
(call: unknown[]) => call[0] === 'modelLoadingEnd'
|
||||
)
|
||||
expect(endEmits).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('logs and drops the load when the URL is missing a filename param', async () => {
|
||||
const { lm, modelManager } = makeLoaderManager()
|
||||
const consoleError = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
await lm.loadModel('api/view?type=output', 'uploads/file.glb')
|
||||
|
||||
expect(consoleError).toHaveBeenCalledWith(
|
||||
'Missing filename in URL:',
|
||||
'api/view?type=output'
|
||||
)
|
||||
expect(modelManager.setupModel).not.toHaveBeenCalled()
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
|
||||
it('proxies setOriginalModel and registerOriginalMaterial through the load context', async () => {
|
||||
const { lm, modelManager } = makeLoaderManager()
|
||||
let capturedCtx: ModelLoadContext | undefined
|
||||
meshLoad.mockImplementationOnce(async (ctx: ModelLoadContext) => {
|
||||
capturedCtx = ctx
|
||||
return new THREE.Object3D()
|
||||
})
|
||||
|
||||
await lm.loadModel('api/view?filename=cube.glb')
|
||||
|
||||
const mesh = new THREE.Mesh(
|
||||
new THREE.BufferGeometry(),
|
||||
new THREE.MeshBasicMaterial()
|
||||
)
|
||||
const mat = new THREE.MeshStandardMaterial()
|
||||
capturedCtx!.setOriginalModel(mesh)
|
||||
capturedCtx!.registerOriginalMaterial(mesh, mat)
|
||||
|
||||
expect(modelManager.setOriginalModel).toHaveBeenCalledWith(mesh)
|
||||
expect(modelManager.originalMaterials.get(mesh)).toBe(mat)
|
||||
})
|
||||
|
||||
it('exposes modelManager.standardMaterial and materialMode via getters on the load context', async () => {
|
||||
const { lm, modelManager } = makeLoaderManager()
|
||||
modelManager.materialMode = 'wireframe'
|
||||
let capturedCtx: ModelLoadContext | undefined
|
||||
meshLoad.mockImplementationOnce(async (ctx: ModelLoadContext) => {
|
||||
capturedCtx = ctx
|
||||
return new THREE.Object3D()
|
||||
})
|
||||
|
||||
await lm.loadModel('api/view?filename=cube.glb')
|
||||
|
||||
expect(capturedCtx!.standardMaterial).toBe(modelManager.standardMaterial)
|
||||
expect(capturedCtx!.materialMode).toBe('wireframe')
|
||||
})
|
||||
|
||||
it('suppresses alerts and modelLoadingEnd when a stale load throws', async () => {
|
||||
const { lm, eventManager } = makeLoaderManager()
|
||||
|
||||
let rejectFirst!: (err: unknown) => void
|
||||
const firstLoad = new Promise<THREE.Object3D>((_, r) => {
|
||||
rejectFirst = r
|
||||
})
|
||||
|
||||
meshLoad
|
||||
.mockImplementationOnce(() => firstLoad)
|
||||
.mockResolvedValueOnce(new THREE.Object3D())
|
||||
|
||||
const consoleError = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
const firstPromise = lm.loadModel('api/view?filename=first.glb')
|
||||
const secondPromise = lm.loadModel('api/view?filename=second.glb')
|
||||
|
||||
rejectFirst(new Error('stale failure'))
|
||||
|
||||
await Promise.all([firstPromise, secondPromise])
|
||||
|
||||
expect(addAlert).not.toHaveBeenCalled()
|
||||
const endEmits = eventManager.emitEvent.mock.calls.filter(
|
||||
(call: unknown[]) => call[0] === 'modelLoadingEnd'
|
||||
)
|
||||
expect(endEmits).toHaveLength(1)
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,49 +1,60 @@
|
||||
import type * as THREE from 'three'
|
||||
import { SplatMesh } from '@sparkjsdev/spark'
|
||||
import * as THREE from 'three'
|
||||
import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader'
|
||||
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
|
||||
import { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader'
|
||||
import { PLYLoader } from 'three/examples/jsm/loaders/PLYLoader'
|
||||
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader'
|
||||
import { MtlObjBridge, OBJLoader2Parallel } from 'wwobjloader2'
|
||||
// Use pre-bundled worker module (has all dependencies included)
|
||||
// The unbundled 'wwobjloader2/worker' has ES imports that fail in production builds
|
||||
import OBJLoader2WorkerUrl from 'wwobjloader2/bundle/worker/module?url'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { api } from '@/scripts/api'
|
||||
import { isPLYAsciiFormat } from '@/scripts/metadata/ply'
|
||||
|
||||
import { MeshModelAdapter } from './MeshModelAdapter'
|
||||
import type { ModelAdapter, ModelLoadContext } from './ModelAdapter'
|
||||
import { PointCloudModelAdapter, getPLYEngine } from './PointCloudModelAdapter'
|
||||
import { SplatModelAdapter } from './SplatModelAdapter'
|
||||
import {
|
||||
type EventManagerInterface,
|
||||
type LoaderManagerInterface,
|
||||
type ModelManagerInterface
|
||||
} from './interfaces'
|
||||
|
||||
/**
|
||||
* Default adapter set: mesh + pointCloud + splat. Each adapter declares the
|
||||
* file extensions it owns; LoaderManager picks one by extension.
|
||||
*/
|
||||
function defaultAdapters(): ModelAdapter[] {
|
||||
return [
|
||||
new MeshModelAdapter(),
|
||||
new PointCloudModelAdapter(),
|
||||
new SplatModelAdapter()
|
||||
]
|
||||
}
|
||||
import { FastPLYLoader } from './loader/FastPLYLoader'
|
||||
|
||||
export class LoaderManager implements LoaderManagerInterface {
|
||||
private readonly modelManager: ModelManagerInterface
|
||||
private readonly eventManager: EventManagerInterface
|
||||
private readonly adapters: ModelAdapter[]
|
||||
gltfLoader: GLTFLoader
|
||||
objLoader: OBJLoader2Parallel
|
||||
mtlLoader: MTLLoader
|
||||
fbxLoader: FBXLoader
|
||||
stlLoader: STLLoader
|
||||
plyLoader: PLYLoader
|
||||
fastPlyLoader: FastPLYLoader
|
||||
|
||||
private modelManager: ModelManagerInterface
|
||||
private eventManager: EventManagerInterface
|
||||
private currentLoadId: number = 0
|
||||
private _currentAdapter: ModelAdapter | null = null
|
||||
|
||||
constructor(
|
||||
modelManager: ModelManagerInterface,
|
||||
eventManager: EventManagerInterface,
|
||||
adapters?: readonly ModelAdapter[]
|
||||
eventManager: EventManagerInterface
|
||||
) {
|
||||
this.modelManager = modelManager
|
||||
this.eventManager = eventManager
|
||||
this.adapters = adapters ? [...adapters] : defaultAdapters()
|
||||
}
|
||||
|
||||
getCurrentAdapter(): ModelAdapter | null {
|
||||
return this._currentAdapter
|
||||
this.gltfLoader = new GLTFLoader()
|
||||
this.objLoader = new OBJLoader2Parallel()
|
||||
// Set worker URL for Vite compatibility
|
||||
this.objLoader.setWorkerUrl(
|
||||
true,
|
||||
new URL(OBJLoader2WorkerUrl, import.meta.url)
|
||||
)
|
||||
this.mtlLoader = new MTLLoader()
|
||||
this.fbxLoader = new FBXLoader()
|
||||
this.stlLoader = new STLLoader()
|
||||
this.plyLoader = new PLYLoader()
|
||||
this.fastPlyLoader = new FastPLYLoader()
|
||||
}
|
||||
|
||||
init(): void {}
|
||||
@@ -57,7 +68,6 @@ export class LoaderManager implements LoaderManagerInterface {
|
||||
this.eventManager.emitEvent('modelLoadingStart', null)
|
||||
|
||||
this.modelManager.clearModel()
|
||||
this._currentAdapter = null
|
||||
|
||||
this.modelManager.originalURL = url
|
||||
|
||||
@@ -70,9 +80,12 @@ export class LoaderManager implements LoaderManagerInterface {
|
||||
} else {
|
||||
const filename = new URLSearchParams(url.split('?')[1]).get('filename')
|
||||
fileExtension = filename?.split('.').pop()?.toLowerCase()
|
||||
this.modelManager.originalFileName = filename
|
||||
? filename.split('.')[0] || 'model'
|
||||
: 'model'
|
||||
|
||||
if (filename) {
|
||||
this.modelManager.originalFileName = filename.split('.')[0] || 'model'
|
||||
} else {
|
||||
this.modelManager.originalFileName = 'model'
|
||||
}
|
||||
}
|
||||
|
||||
if (!fileExtension) {
|
||||
@@ -80,21 +93,19 @@ export class LoaderManager implements LoaderManagerInterface {
|
||||
return
|
||||
}
|
||||
|
||||
const result = await this.loadModelInternal(url, fileExtension)
|
||||
const model = await this.loadModelInternal(url, fileExtension)
|
||||
|
||||
if (loadId !== this.currentLoadId) {
|
||||
return
|
||||
}
|
||||
|
||||
if (result && result.model) {
|
||||
this._currentAdapter = result.adapter
|
||||
await this.modelManager.setupModel(result.model)
|
||||
if (model) {
|
||||
await this.modelManager.setupModel(model)
|
||||
}
|
||||
|
||||
this.eventManager.emitEvent('modelLoadingEnd', null)
|
||||
} catch (error) {
|
||||
if (loadId === this.currentLoadId) {
|
||||
this._currentAdapter = null
|
||||
this.eventManager.emitEvent('modelLoadingEnd', null)
|
||||
console.error('Error loading model:', error)
|
||||
useToastStore().addAlert(t('toastMessages.errorLoadingModel'))
|
||||
@@ -102,50 +113,26 @@ export class LoaderManager implements LoaderManagerInterface {
|
||||
}
|
||||
}
|
||||
|
||||
private pickAdapter(extension: string): ModelAdapter | null {
|
||||
const match = this.adapters.find((adapter) =>
|
||||
adapter.extensions.includes(extension)
|
||||
)
|
||||
if (!match) return null
|
||||
|
||||
// PLY may be routed through the splat adapter when the PLYEngine setting
|
||||
// is sparkjs. Only honor the routing when both adapters are registered.
|
||||
if (match.kind === 'pointCloud' && getPLYEngine() === 'sparkjs') {
|
||||
const splat = this.adapters.find((adapter) => adapter.kind === 'splat')
|
||||
if (splat) return splat
|
||||
}
|
||||
return match
|
||||
}
|
||||
|
||||
private createLoadContext(): ModelLoadContext {
|
||||
const mm = this.modelManager
|
||||
return {
|
||||
setOriginalModel: (model) => mm.setOriginalModel(model),
|
||||
registerOriginalMaterial: (mesh, material) =>
|
||||
mm.originalMaterials.set(mesh, material),
|
||||
get standardMaterial() {
|
||||
return mm.standardMaterial
|
||||
},
|
||||
get materialMode() {
|
||||
return mm.materialMode
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async loadModelInternal(
|
||||
url: string,
|
||||
fileExtension: string
|
||||
): Promise<{ adapter: ModelAdapter; model: THREE.Object3D | null } | null> {
|
||||
): Promise<THREE.Object3D | null> {
|
||||
let model: THREE.Object3D | null = null
|
||||
|
||||
const params = new URLSearchParams(url.split('?')[1])
|
||||
|
||||
const filename = params.get('filename')
|
||||
|
||||
if (!filename) {
|
||||
console.error('Missing filename in URL:', url)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const loadRootFolder = params.get('type') === 'output' ? 'output' : 'input'
|
||||
|
||||
const subfolder = params.get('subfolder') ?? ''
|
||||
|
||||
const path =
|
||||
'api/view?type=' +
|
||||
loadRootFolder +
|
||||
@@ -153,10 +140,217 @@ export class LoaderManager implements LoaderManagerInterface {
|
||||
encodeURIComponent(subfolder) +
|
||||
'&filename='
|
||||
|
||||
const adapter = this.pickAdapter(fileExtension)
|
||||
if (!adapter) return null
|
||||
switch (fileExtension) {
|
||||
case 'stl':
|
||||
this.stlLoader.setPath(path)
|
||||
const geometry = await this.stlLoader.loadAsync(filename)
|
||||
this.modelManager.setOriginalModel(geometry)
|
||||
geometry.computeVertexNormals()
|
||||
|
||||
const model = await adapter.load(this.createLoadContext(), path, filename)
|
||||
return { adapter, model }
|
||||
const mesh = new THREE.Mesh(
|
||||
geometry,
|
||||
this.modelManager.standardMaterial
|
||||
)
|
||||
|
||||
const group = new THREE.Group()
|
||||
group.add(mesh)
|
||||
model = group
|
||||
break
|
||||
|
||||
case 'fbx':
|
||||
this.fbxLoader.setPath(path)
|
||||
|
||||
const fbxModel = await this.fbxLoader.loadAsync(filename)
|
||||
|
||||
this.modelManager.setOriginalModel(fbxModel)
|
||||
model = fbxModel
|
||||
|
||||
fbxModel.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
this.modelManager.originalMaterials.set(child, child.material)
|
||||
|
||||
if (child instanceof THREE.SkinnedMesh) {
|
||||
child.frustumCulled = false
|
||||
}
|
||||
}
|
||||
})
|
||||
break
|
||||
|
||||
case 'obj':
|
||||
if (this.modelManager.materialMode === 'original') {
|
||||
try {
|
||||
this.mtlLoader.setPath(path)
|
||||
|
||||
const mtlFileName = filename.replace(/\.obj$/, '.mtl')
|
||||
|
||||
const materials = await this.mtlLoader.loadAsync(mtlFileName)
|
||||
materials.preload()
|
||||
const materialsFromMtl =
|
||||
MtlObjBridge.addMaterialsFromMtlLoader(materials)
|
||||
this.objLoader.setMaterials(materialsFromMtl)
|
||||
} catch (e) {
|
||||
console.log(
|
||||
'No MTL file found or error loading it, continuing without materials'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// OBJLoader2Parallel uses Web Worker for parsing (non-blocking)
|
||||
const objUrl = path + encodeURIComponent(filename)
|
||||
model = await this.objLoader.loadAsync(objUrl)
|
||||
|
||||
model.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
this.modelManager.originalMaterials.set(child, child.material)
|
||||
}
|
||||
})
|
||||
break
|
||||
|
||||
case 'gltf':
|
||||
case 'glb':
|
||||
this.gltfLoader.setPath(path)
|
||||
const gltf = await this.gltfLoader.loadAsync(filename)
|
||||
|
||||
this.modelManager.setOriginalModel(gltf)
|
||||
model = gltf.scene
|
||||
|
||||
gltf.scene.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
child.geometry.computeVertexNormals()
|
||||
this.modelManager.originalMaterials.set(child, child.material)
|
||||
|
||||
if (child instanceof THREE.SkinnedMesh) {
|
||||
child.frustumCulled = false
|
||||
}
|
||||
}
|
||||
})
|
||||
break
|
||||
|
||||
case 'ply':
|
||||
model = await this.loadPLY(path, filename)
|
||||
break
|
||||
|
||||
case 'spz':
|
||||
case 'splat':
|
||||
case 'ksplat':
|
||||
model = await this.loadSplat(path, filename)
|
||||
break
|
||||
}
|
||||
|
||||
return model
|
||||
}
|
||||
|
||||
private async fetchModelData(path: string, filename: string) {
|
||||
const route =
|
||||
'/' + path.replace(/^api\//, '') + encodeURIComponent(filename)
|
||||
const response = await api.fetchApi(route)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch model: ${response.status}`)
|
||||
}
|
||||
return response.arrayBuffer()
|
||||
}
|
||||
|
||||
private async loadSplat(
|
||||
path: string,
|
||||
filename: string
|
||||
): Promise<THREE.Object3D> {
|
||||
const arrayBuffer = await this.fetchModelData(path, filename)
|
||||
|
||||
const splatMesh = new SplatMesh({ fileBytes: arrayBuffer })
|
||||
this.modelManager.setOriginalModel(splatMesh)
|
||||
const splatGroup = new THREE.Group()
|
||||
splatGroup.add(splatMesh)
|
||||
return splatGroup
|
||||
}
|
||||
|
||||
private async loadPLY(
|
||||
path: string,
|
||||
filename: string
|
||||
): Promise<THREE.Object3D | null> {
|
||||
const plyEngine = useSettingStore().get('Comfy.Load3D.PLYEngine') as string
|
||||
|
||||
if (plyEngine === 'sparkjs') {
|
||||
return this.loadSplat(path, filename)
|
||||
}
|
||||
|
||||
// Use Three.js PLYLoader or FastPLYLoader for point cloud PLY files
|
||||
const arrayBuffer = await this.fetchModelData(path, filename)
|
||||
|
||||
const isASCII = isPLYAsciiFormat(arrayBuffer)
|
||||
|
||||
let plyGeometry: THREE.BufferGeometry
|
||||
|
||||
if (isASCII && plyEngine === 'fastply') {
|
||||
plyGeometry = this.fastPlyLoader.parse(arrayBuffer)
|
||||
} else {
|
||||
this.plyLoader.setPath(path)
|
||||
plyGeometry = this.plyLoader.parse(arrayBuffer)
|
||||
}
|
||||
|
||||
this.modelManager.setOriginalModel(plyGeometry)
|
||||
plyGeometry.computeVertexNormals()
|
||||
|
||||
const hasVertexColors = plyGeometry.attributes.color !== undefined
|
||||
const materialMode = this.modelManager.materialMode
|
||||
|
||||
// Use Points rendering for pointCloud mode (better for point clouds)
|
||||
if (materialMode === 'pointCloud') {
|
||||
plyGeometry.computeBoundingSphere()
|
||||
if (plyGeometry.boundingSphere) {
|
||||
const center = plyGeometry.boundingSphere.center
|
||||
const radius = plyGeometry.boundingSphere.radius
|
||||
|
||||
plyGeometry.translate(-center.x, -center.y, -center.z)
|
||||
|
||||
if (radius > 0) {
|
||||
const scale = 1.0 / radius
|
||||
plyGeometry.scale(scale, scale, scale)
|
||||
}
|
||||
}
|
||||
|
||||
const pointMaterial = hasVertexColors
|
||||
? new THREE.PointsMaterial({
|
||||
size: 0.005,
|
||||
vertexColors: true,
|
||||
sizeAttenuation: true
|
||||
})
|
||||
: new THREE.PointsMaterial({
|
||||
size: 0.005,
|
||||
color: 0xcccccc,
|
||||
sizeAttenuation: true
|
||||
})
|
||||
|
||||
const plyPoints = new THREE.Points(plyGeometry, pointMaterial)
|
||||
this.modelManager.originalMaterials.set(
|
||||
plyPoints as unknown as THREE.Mesh,
|
||||
pointMaterial
|
||||
)
|
||||
|
||||
const plyGroup = new THREE.Group()
|
||||
plyGroup.add(plyPoints)
|
||||
return plyGroup
|
||||
}
|
||||
|
||||
// Use Mesh rendering for other modes
|
||||
let plyMaterial: THREE.Material
|
||||
|
||||
if (hasVertexColors) {
|
||||
plyMaterial = new THREE.MeshStandardMaterial({
|
||||
vertexColors: true,
|
||||
metalness: 0.0,
|
||||
roughness: 0.5,
|
||||
side: THREE.DoubleSide
|
||||
})
|
||||
} else {
|
||||
plyMaterial = this.modelManager.standardMaterial.clone()
|
||||
plyMaterial.side = THREE.DoubleSide
|
||||
}
|
||||
|
||||
const plyMesh = new THREE.Mesh(plyGeometry, plyMaterial)
|
||||
this.modelManager.originalMaterials.set(plyMesh, plyMaterial)
|
||||
|
||||
const plyGroup = new THREE.Group()
|
||||
plyGroup.add(plyMesh)
|
||||
return plyGroup
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,302 +0,0 @@
|
||||
import * as THREE from 'three'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { MeshModelAdapter } from './MeshModelAdapter'
|
||||
import type { ModelLoadContext } from './ModelAdapter'
|
||||
|
||||
const stlLoaderStub = {
|
||||
setPath: vi.fn(),
|
||||
loadAsync: vi.fn<(filename: string) => Promise<THREE.BufferGeometry>>()
|
||||
}
|
||||
const fbxLoaderStub = {
|
||||
setPath: vi.fn(),
|
||||
loadAsync: vi.fn<(filename: string) => Promise<THREE.Object3D>>()
|
||||
}
|
||||
const gltfLoaderStub = {
|
||||
setPath: vi.fn(),
|
||||
loadAsync: vi.fn<(filename: string) => Promise<{ scene: THREE.Object3D }>>()
|
||||
}
|
||||
const mtlLoaderStub = {
|
||||
setPath: vi.fn(),
|
||||
loadAsync: vi.fn<(filename: string) => Promise<{ preload: () => void }>>()
|
||||
}
|
||||
const objLoaderStub = {
|
||||
setWorkerUrl: vi.fn(),
|
||||
setMaterials: vi.fn(),
|
||||
loadAsync: vi.fn<(url: string) => Promise<THREE.Object3D>>()
|
||||
}
|
||||
|
||||
vi.mock('three/examples/jsm/loaders/STLLoader', () => ({
|
||||
STLLoader: class {
|
||||
setPath = stlLoaderStub.setPath
|
||||
loadAsync = stlLoaderStub.loadAsync
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('three/examples/jsm/loaders/FBXLoader', () => ({
|
||||
FBXLoader: class {
|
||||
setPath = fbxLoaderStub.setPath
|
||||
loadAsync = fbxLoaderStub.loadAsync
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('three/examples/jsm/loaders/GLTFLoader', () => ({
|
||||
GLTFLoader: class {
|
||||
setPath = gltfLoaderStub.setPath
|
||||
loadAsync = gltfLoaderStub.loadAsync
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('three/examples/jsm/loaders/MTLLoader', () => ({
|
||||
MTLLoader: class {
|
||||
setPath = mtlLoaderStub.setPath
|
||||
loadAsync = mtlLoaderStub.loadAsync
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('wwobjloader2', () => ({
|
||||
OBJLoader2Parallel: class {
|
||||
setWorkerUrl = objLoaderStub.setWorkerUrl
|
||||
setMaterials = objLoaderStub.setMaterials
|
||||
loadAsync = objLoaderStub.loadAsync
|
||||
},
|
||||
MtlObjBridge: {
|
||||
addMaterialsFromMtlLoader: vi.fn().mockReturnValue([])
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('wwobjloader2/bundle/worker/module?url', () => ({
|
||||
default: 'mock-worker-url'
|
||||
}))
|
||||
|
||||
function makeContext(
|
||||
materialMode: ModelLoadContext['materialMode'] = 'original'
|
||||
): ModelLoadContext {
|
||||
return {
|
||||
setOriginalModel: vi.fn(),
|
||||
registerOriginalMaterial: vi.fn(),
|
||||
standardMaterial: new THREE.MeshStandardMaterial(),
|
||||
materialMode
|
||||
}
|
||||
}
|
||||
|
||||
function makeFbxLikeGroup(): THREE.Group {
|
||||
const group = new THREE.Group()
|
||||
const mesh = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(),
|
||||
new THREE.MeshStandardMaterial()
|
||||
)
|
||||
group.add(mesh)
|
||||
return group
|
||||
}
|
||||
|
||||
describe('MeshModelAdapter', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('identity', () => {
|
||||
it('identifies as a mesh adapter with full capabilities', () => {
|
||||
const adapter = new MeshModelAdapter()
|
||||
expect(adapter.kind).toBe('mesh')
|
||||
expect(adapter.capabilities.fitToViewer).toBe(true)
|
||||
expect(adapter.capabilities.requiresMaterialRebuild).toBe(false)
|
||||
expect(adapter.capabilities.gizmoTransform).toBe(true)
|
||||
expect(adapter.capabilities.lighting).toBe(true)
|
||||
expect(adapter.capabilities.exportable).toBe(true)
|
||||
expect([...adapter.capabilities.materialModes]).toEqual([
|
||||
'original',
|
||||
'normal',
|
||||
'wireframe'
|
||||
])
|
||||
})
|
||||
|
||||
it('handles the expected mesh extensions', () => {
|
||||
const adapter = new MeshModelAdapter()
|
||||
expect([...adapter.extensions]).toEqual([
|
||||
'stl',
|
||||
'fbx',
|
||||
'obj',
|
||||
'gltf',
|
||||
'glb'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('dispatch fallbacks', () => {
|
||||
it('returns null when the filename extension belongs to another adapter', async () => {
|
||||
const adapter = new MeshModelAdapter()
|
||||
const result = await adapter.load(makeContext(), '/path/', 'cloud.ply')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for an unknown extension', async () => {
|
||||
const adapter = new MeshModelAdapter()
|
||||
const result = await adapter.load(makeContext(), '/path/', 'data.xyz')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for a filename without an extension', async () => {
|
||||
const adapter = new MeshModelAdapter()
|
||||
const result = await adapter.load(makeContext(), '/path/', 'noextension')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('STL loader path', () => {
|
||||
it('loads STL geometry and wraps it in a Group with a Mesh child', async () => {
|
||||
const geometry = new THREE.BufferGeometry()
|
||||
geometry.setAttribute(
|
||||
'position',
|
||||
new THREE.Float32BufferAttribute([0, 0, 0, 1, 0, 0, 0, 1, 0], 3)
|
||||
)
|
||||
stlLoaderStub.loadAsync.mockResolvedValue(geometry)
|
||||
|
||||
const adapter = new MeshModelAdapter()
|
||||
const ctx = makeContext()
|
||||
|
||||
const result = await adapter.load(ctx, '/api/view/', 'model.stl')
|
||||
|
||||
expect(stlLoaderStub.setPath).toHaveBeenCalledWith('/api/view/')
|
||||
expect(stlLoaderStub.loadAsync).toHaveBeenCalledWith('model.stl')
|
||||
expect(ctx.setOriginalModel).toHaveBeenCalledWith(geometry)
|
||||
expect(result).toBeInstanceOf(THREE.Group)
|
||||
expect(result!.children[0]).toBeInstanceOf(THREE.Mesh)
|
||||
})
|
||||
})
|
||||
|
||||
describe('FBX loader path', () => {
|
||||
it('loads an FBX model and registers its mesh materials', async () => {
|
||||
const fbxModel = makeFbxLikeGroup()
|
||||
fbxLoaderStub.loadAsync.mockResolvedValue(fbxModel)
|
||||
|
||||
const adapter = new MeshModelAdapter()
|
||||
const ctx = makeContext()
|
||||
|
||||
const result = await adapter.load(ctx, '/api/view/', 'rig.fbx')
|
||||
|
||||
expect(fbxLoaderStub.setPath).toHaveBeenCalledWith('/api/view/')
|
||||
expect(fbxLoaderStub.loadAsync).toHaveBeenCalledWith('rig.fbx')
|
||||
expect(ctx.setOriginalModel).toHaveBeenCalledWith(fbxModel)
|
||||
expect(ctx.registerOriginalMaterial).toHaveBeenCalledTimes(1)
|
||||
expect(result).toBe(fbxModel)
|
||||
})
|
||||
|
||||
it('disables frustum culling on SkinnedMesh children', async () => {
|
||||
const group = new THREE.Group()
|
||||
const skinned = new THREE.SkinnedMesh(
|
||||
new THREE.BoxGeometry(),
|
||||
new THREE.MeshStandardMaterial()
|
||||
)
|
||||
skinned.frustumCulled = true
|
||||
group.add(skinned)
|
||||
fbxLoaderStub.loadAsync.mockResolvedValue(group)
|
||||
|
||||
const adapter = new MeshModelAdapter()
|
||||
await adapter.load(makeContext(), '/api/view/', 'animated.fbx')
|
||||
|
||||
expect(skinned.frustumCulled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('OBJ loader path', () => {
|
||||
it('attempts the MTL sidecar in original material mode', async () => {
|
||||
mtlLoaderStub.loadAsync.mockResolvedValue({ preload: vi.fn() })
|
||||
objLoaderStub.loadAsync.mockResolvedValue(makeFbxLikeGroup())
|
||||
|
||||
const adapter = new MeshModelAdapter()
|
||||
await adapter.load(makeContext('original'), '/api/view/', 'cube.obj')
|
||||
|
||||
expect(mtlLoaderStub.setPath).toHaveBeenCalledWith('/api/view/')
|
||||
expect(mtlLoaderStub.loadAsync).toHaveBeenCalledWith('cube.mtl')
|
||||
expect(objLoaderStub.setMaterials).toHaveBeenCalled()
|
||||
expect(objLoaderStub.loadAsync).toHaveBeenCalledWith('/api/view/cube.obj')
|
||||
})
|
||||
|
||||
it('swallows MTL load errors and continues without materials', async () => {
|
||||
mtlLoaderStub.loadAsync.mockRejectedValue(new Error('no mtl'))
|
||||
objLoaderStub.loadAsync.mockResolvedValue(makeFbxLikeGroup())
|
||||
|
||||
const adapter = new MeshModelAdapter()
|
||||
const result = await adapter.load(
|
||||
makeContext('original'),
|
||||
'/api/view/',
|
||||
'cube.obj'
|
||||
)
|
||||
|
||||
expect(result).toBeInstanceOf(THREE.Group)
|
||||
expect(objLoaderStub.setMaterials).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips the MTL attempt for non-original material modes', async () => {
|
||||
objLoaderStub.loadAsync.mockResolvedValue(makeFbxLikeGroup())
|
||||
|
||||
const adapter = new MeshModelAdapter()
|
||||
await adapter.load(makeContext('wireframe'), '/api/view/', 'cube.obj')
|
||||
|
||||
expect(mtlLoaderStub.loadAsync).not.toHaveBeenCalled()
|
||||
expect(objLoaderStub.loadAsync).toHaveBeenCalledWith('/api/view/cube.obj')
|
||||
})
|
||||
|
||||
it('registers materials for each mesh child', async () => {
|
||||
objLoaderStub.loadAsync.mockResolvedValue(makeFbxLikeGroup())
|
||||
|
||||
const adapter = new MeshModelAdapter()
|
||||
const ctx = makeContext('wireframe')
|
||||
await adapter.load(ctx, '/api/view/', 'cube.obj')
|
||||
|
||||
expect(ctx.registerOriginalMaterial).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GLTF loader path', () => {
|
||||
it('loads a .glb and returns the scene with vertex normals computed', async () => {
|
||||
const mesh = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(),
|
||||
new THREE.MeshStandardMaterial()
|
||||
)
|
||||
const computeNormals = vi.spyOn(mesh.geometry, 'computeVertexNormals')
|
||||
const scene = new THREE.Group()
|
||||
scene.add(mesh)
|
||||
const gltf = { scene }
|
||||
gltfLoaderStub.loadAsync.mockResolvedValue(gltf)
|
||||
|
||||
const adapter = new MeshModelAdapter()
|
||||
const ctx = makeContext()
|
||||
|
||||
const result = await adapter.load(ctx, '/api/view/', 'scene.glb')
|
||||
|
||||
expect(gltfLoaderStub.setPath).toHaveBeenCalledWith('/api/view/')
|
||||
expect(gltfLoaderStub.loadAsync).toHaveBeenCalledWith('scene.glb')
|
||||
expect(ctx.setOriginalModel).toHaveBeenCalledWith(gltf)
|
||||
expect(computeNormals).toHaveBeenCalled()
|
||||
expect(ctx.registerOriginalMaterial).toHaveBeenCalledTimes(1)
|
||||
expect(result).toBe(scene)
|
||||
})
|
||||
|
||||
it('also handles .gltf filenames', async () => {
|
||||
gltfLoaderStub.loadAsync.mockResolvedValue({ scene: new THREE.Group() })
|
||||
|
||||
const adapter = new MeshModelAdapter()
|
||||
await adapter.load(makeContext(), '/api/view/', 'scene.gltf')
|
||||
|
||||
expect(gltfLoaderStub.loadAsync).toHaveBeenCalledWith('scene.gltf')
|
||||
})
|
||||
|
||||
it('disables frustum culling on SkinnedMesh children inside the scene', async () => {
|
||||
const scene = new THREE.Group()
|
||||
const skinned = new THREE.SkinnedMesh(
|
||||
new THREE.BoxGeometry(),
|
||||
new THREE.MeshStandardMaterial()
|
||||
)
|
||||
skinned.frustumCulled = true
|
||||
scene.add(skinned)
|
||||
gltfLoaderStub.loadAsync.mockResolvedValue({ scene })
|
||||
|
||||
const adapter = new MeshModelAdapter()
|
||||
await adapter.load(makeContext(), '/api/view/', 'rigged.glb')
|
||||
|
||||
expect(skinned.frustumCulled).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,155 +0,0 @@
|
||||
import * as THREE from 'three'
|
||||
import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader'
|
||||
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
|
||||
import { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader'
|
||||
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader'
|
||||
import { MtlObjBridge, OBJLoader2Parallel } from 'wwobjloader2'
|
||||
// Use pre-bundled worker module (has all dependencies included).
|
||||
// The unbundled 'wwobjloader2/worker' has ES imports that fail in production builds.
|
||||
import OBJLoader2WorkerUrl from 'wwobjloader2/bundle/worker/module?url'
|
||||
|
||||
import type {
|
||||
ModelAdapter,
|
||||
ModelAdapterCapabilities,
|
||||
ModelLoadContext
|
||||
} from './ModelAdapter'
|
||||
|
||||
export class MeshModelAdapter implements ModelAdapter {
|
||||
readonly kind = 'mesh' as const
|
||||
readonly extensions = ['stl', 'fbx', 'obj', 'gltf', 'glb'] as const
|
||||
readonly capabilities: ModelAdapterCapabilities = {
|
||||
fitToViewer: true,
|
||||
requiresMaterialRebuild: false,
|
||||
gizmoTransform: true,
|
||||
lighting: true,
|
||||
exportable: true,
|
||||
materialModes: ['original', 'normal', 'wireframe'],
|
||||
fitTargetSize: 5
|
||||
}
|
||||
|
||||
private readonly gltfLoader = new GLTFLoader()
|
||||
private readonly objLoader: OBJLoader2Parallel
|
||||
private readonly mtlLoader = new MTLLoader()
|
||||
private readonly fbxLoader = new FBXLoader()
|
||||
private readonly stlLoader = new STLLoader()
|
||||
|
||||
constructor() {
|
||||
this.objLoader = new OBJLoader2Parallel()
|
||||
this.objLoader.setWorkerUrl(
|
||||
true,
|
||||
new URL(OBJLoader2WorkerUrl, import.meta.url)
|
||||
)
|
||||
}
|
||||
|
||||
async load(
|
||||
ctx: ModelLoadContext,
|
||||
path: string,
|
||||
filename: string
|
||||
): Promise<THREE.Object3D | null> {
|
||||
const extension = filename.split('.').pop()?.toLowerCase()
|
||||
switch (extension) {
|
||||
case 'stl':
|
||||
return this.loadSTL(ctx, path, filename)
|
||||
case 'fbx':
|
||||
return this.loadFBX(ctx, path, filename)
|
||||
case 'obj':
|
||||
return this.loadOBJ(ctx, path, filename)
|
||||
case 'gltf':
|
||||
case 'glb':
|
||||
return this.loadGLTF(ctx, path, filename)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private async loadSTL(
|
||||
ctx: ModelLoadContext,
|
||||
path: string,
|
||||
filename: string
|
||||
): Promise<THREE.Object3D> {
|
||||
this.stlLoader.setPath(path)
|
||||
const geometry = await this.stlLoader.loadAsync(filename)
|
||||
ctx.setOriginalModel(geometry)
|
||||
geometry.computeVertexNormals()
|
||||
|
||||
const mesh = new THREE.Mesh(geometry, ctx.standardMaterial)
|
||||
const group = new THREE.Group()
|
||||
group.add(mesh)
|
||||
return group
|
||||
}
|
||||
|
||||
private async loadFBX(
|
||||
ctx: ModelLoadContext,
|
||||
path: string,
|
||||
filename: string
|
||||
): Promise<THREE.Object3D> {
|
||||
this.fbxLoader.setPath(path)
|
||||
const fbxModel = await this.fbxLoader.loadAsync(filename)
|
||||
ctx.setOriginalModel(fbxModel)
|
||||
|
||||
fbxModel.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
ctx.registerOriginalMaterial(child, child.material)
|
||||
if (child instanceof THREE.SkinnedMesh) {
|
||||
child.frustumCulled = false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return fbxModel
|
||||
}
|
||||
|
||||
private async loadOBJ(
|
||||
ctx: ModelLoadContext,
|
||||
path: string,
|
||||
filename: string
|
||||
): Promise<THREE.Object3D> {
|
||||
if (ctx.materialMode === 'original') {
|
||||
try {
|
||||
this.mtlLoader.setPath(path)
|
||||
const mtlFileName = filename.replace(/\.obj$/i, '.mtl')
|
||||
const materials = await this.mtlLoader.loadAsync(mtlFileName)
|
||||
materials.preload()
|
||||
const materialsFromMtl =
|
||||
MtlObjBridge.addMaterialsFromMtlLoader(materials)
|
||||
this.objLoader.setMaterials(materialsFromMtl)
|
||||
} catch {
|
||||
console.log(
|
||||
'No MTL file found or error loading it, continuing without materials'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const objUrl = path + encodeURIComponent(filename)
|
||||
const model = await this.objLoader.loadAsync(objUrl)
|
||||
|
||||
model.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
ctx.registerOriginalMaterial(child, child.material)
|
||||
}
|
||||
})
|
||||
|
||||
return model
|
||||
}
|
||||
|
||||
private async loadGLTF(
|
||||
ctx: ModelLoadContext,
|
||||
path: string,
|
||||
filename: string
|
||||
): Promise<THREE.Object3D> {
|
||||
this.gltfLoader.setPath(path)
|
||||
const gltf = await this.gltfLoader.loadAsync(filename)
|
||||
ctx.setOriginalModel(gltf)
|
||||
|
||||
gltf.scene.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
child.geometry.computeVertexNormals()
|
||||
ctx.registerOriginalMaterial(child, child.material)
|
||||
if (child instanceof THREE.SkinnedMesh) {
|
||||
child.frustumCulled = false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return gltf.scene
|
||||
}
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
import { DEFAULT_MODEL_CAPABILITIES, fetchModelData } from './ModelAdapter'
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
fetchApi: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
describe('DEFAULT_MODEL_CAPABILITIES', () => {
|
||||
it('enables fit-to-viewer / gizmo / lighting / export by default', () => {
|
||||
expect(DEFAULT_MODEL_CAPABILITIES.fitToViewer).toBe(true)
|
||||
expect(DEFAULT_MODEL_CAPABILITIES.requiresMaterialRebuild).toBe(false)
|
||||
expect(DEFAULT_MODEL_CAPABILITIES.gizmoTransform).toBe(true)
|
||||
expect(DEFAULT_MODEL_CAPABILITIES.lighting).toBe(true)
|
||||
expect(DEFAULT_MODEL_CAPABILITIES.exportable).toBe(true)
|
||||
expect([...DEFAULT_MODEL_CAPABILITIES.materialModes]).toEqual([
|
||||
'original',
|
||||
'normal',
|
||||
'wireframe'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchModelData', () => {
|
||||
const mockFetchApi = vi.mocked(api.fetchApi)
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetchApi.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('returns the arrayBuffer on a successful response', async () => {
|
||||
const buf = new ArrayBuffer(8)
|
||||
mockFetchApi.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
arrayBuffer: vi.fn().mockResolvedValue(buf)
|
||||
} as unknown as Response)
|
||||
|
||||
const result = await fetchModelData('api/view?...&filename=', 'model.glb')
|
||||
|
||||
expect(result).toBe(buf)
|
||||
})
|
||||
|
||||
it('throws with status code when the response is not ok', async () => {
|
||||
mockFetchApi.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404
|
||||
} as unknown as Response)
|
||||
|
||||
await expect(
|
||||
fetchModelData('api/view?type=input&subfolder=&filename=', 'missing.glb')
|
||||
).rejects.toThrow('Failed to fetch model: 404')
|
||||
})
|
||||
|
||||
it('strips the leading api/ prefix and encodes the filename', async () => {
|
||||
mockFetchApi.mockResolvedValue({
|
||||
ok: true,
|
||||
arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(0))
|
||||
} as unknown as Response)
|
||||
|
||||
await fetchModelData(
|
||||
'api/view?type=input&subfolder=&filename=',
|
||||
'a b c.ply'
|
||||
)
|
||||
|
||||
expect(mockFetchApi).toHaveBeenCalledWith(
|
||||
'/view?type=input&subfolder=&filename=a%20b%20c.ply'
|
||||
)
|
||||
})
|
||||
|
||||
it('prepends a single slash when the path has no api/ prefix', async () => {
|
||||
mockFetchApi.mockResolvedValue({
|
||||
ok: true,
|
||||
arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(0))
|
||||
} as unknown as Response)
|
||||
|
||||
await fetchModelData('custom?filename=', 'scene.splat')
|
||||
|
||||
expect(mockFetchApi).toHaveBeenCalledWith('/custom?filename=scene.splat')
|
||||
})
|
||||
})
|
||||
@@ -1,88 +0,0 @@
|
||||
import type * as THREE from 'three'
|
||||
import type { GLTF } from 'three/examples/jsm/loaders/GLTFLoader'
|
||||
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
import type { MaterialMode } from './interfaces'
|
||||
|
||||
export interface ModelLoadContext {
|
||||
setOriginalModel(model: THREE.Object3D | THREE.BufferGeometry | GLTF): void
|
||||
registerOriginalMaterial(
|
||||
mesh: THREE.Mesh,
|
||||
material: THREE.Material | THREE.Material[]
|
||||
): void
|
||||
readonly standardMaterial: THREE.MeshStandardMaterial
|
||||
readonly materialMode: MaterialMode
|
||||
}
|
||||
|
||||
export type ModelAdapterKind = 'mesh' | 'pointCloud' | 'splat'
|
||||
|
||||
export interface ModelAdapterCapabilities {
|
||||
/**
|
||||
* Whether auto-normalize/centering on load and the explicit fit-to-viewer
|
||||
* action should run. Splats render self-sized and are placed at a fixed
|
||||
* camera distance instead.
|
||||
*/
|
||||
fitToViewer: boolean
|
||||
/**
|
||||
* Whether a material mode change must rebuild the scene object instead of
|
||||
* traversing the existing mesh tree. True for point-cloud PLY (Mesh <->
|
||||
* Points swap); false for regular meshes and self-rendering splats.
|
||||
*/
|
||||
requiresMaterialRebuild: boolean
|
||||
/**
|
||||
* Whether the gizmo transform UI (translate/rotate/scale) should be
|
||||
* exposed for this model type. False for adapters whose already-normalized
|
||||
* output makes user transforms meaningless (PLY point cloud).
|
||||
*/
|
||||
gizmoTransform: boolean
|
||||
/** Whether scene-lighting controls apply. False for self-lit formats. */
|
||||
lighting: boolean
|
||||
/** Whether the model can be exported (GLB/OBJ/STL). */
|
||||
exportable: boolean
|
||||
/**
|
||||
* Material modes offered in the UI for this format. An empty array hides
|
||||
* the material-mode dropdown entirely.
|
||||
*/
|
||||
materialModes: readonly MaterialMode[]
|
||||
/**
|
||||
* World-space target size along the largest dimension after
|
||||
* fit-to-viewer normalization. Controls how large the model ends up
|
||||
* relative to the 20-unit scene grid; splats use a larger value so they
|
||||
* don't shrink to a quarter of the floor.
|
||||
*/
|
||||
fitTargetSize: number
|
||||
}
|
||||
|
||||
export const DEFAULT_MODEL_CAPABILITIES: ModelAdapterCapabilities = {
|
||||
fitToViewer: true,
|
||||
requiresMaterialRebuild: false,
|
||||
gizmoTransform: true,
|
||||
lighting: true,
|
||||
exportable: true,
|
||||
materialModes: ['original', 'normal', 'wireframe'],
|
||||
fitTargetSize: 5
|
||||
}
|
||||
|
||||
export interface ModelAdapter {
|
||||
readonly kind: ModelAdapterKind
|
||||
readonly extensions: readonly string[]
|
||||
readonly capabilities: ModelAdapterCapabilities
|
||||
load(
|
||||
ctx: ModelLoadContext,
|
||||
path: string,
|
||||
filename: string
|
||||
): Promise<THREE.Object3D | null>
|
||||
}
|
||||
|
||||
export async function fetchModelData(
|
||||
path: string,
|
||||
filename: string
|
||||
): Promise<ArrayBuffer> {
|
||||
const route = '/' + path.replace(/^api\//, '') + encodeURIComponent(filename)
|
||||
const response = await api.fetchApi(route)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch model: ${response.status}`)
|
||||
}
|
||||
return response.arrayBuffer()
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
import * as THREE from 'three'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ModelLoadContext } from './ModelAdapter'
|
||||
import * as ModelAdapterModule from './ModelAdapter'
|
||||
import { PointCloudModelAdapter } from './PointCloudModelAdapter'
|
||||
|
||||
const mockSettingGet = vi.fn<(key: string) => unknown>()
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({ get: mockSettingGet })
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/metadata/ply', () => ({
|
||||
isPLYAsciiFormat: vi.fn().mockReturnValue(false)
|
||||
}))
|
||||
|
||||
vi.mock('three/examples/jsm/loaders/PLYLoader', () => ({
|
||||
PLYLoader: class {
|
||||
setPath = vi.fn()
|
||||
parse = vi.fn(() => makePLYGeometry(false))
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('./loader/FastPLYLoader', () => ({
|
||||
FastPLYLoader: class {
|
||||
parse = vi.fn(() => makePLYGeometry(false))
|
||||
}
|
||||
}))
|
||||
|
||||
function makePLYGeometry(withColors: boolean): THREE.BufferGeometry {
|
||||
const geometry = new THREE.BufferGeometry()
|
||||
geometry.setAttribute(
|
||||
'position',
|
||||
new THREE.Float32BufferAttribute([0, 0, 0, 1, 0, 0, 0, 1, 0], 3)
|
||||
)
|
||||
if (withColors) {
|
||||
geometry.setAttribute(
|
||||
'color',
|
||||
new THREE.Float32BufferAttribute([1, 0, 0, 0, 1, 0, 0, 0, 1], 3)
|
||||
)
|
||||
}
|
||||
return geometry
|
||||
}
|
||||
|
||||
function makeContext(
|
||||
materialMode: ModelLoadContext['materialMode'] = 'original'
|
||||
): ModelLoadContext {
|
||||
return {
|
||||
setOriginalModel: vi.fn(),
|
||||
registerOriginalMaterial: vi.fn(),
|
||||
standardMaterial: new THREE.MeshStandardMaterial(),
|
||||
materialMode
|
||||
}
|
||||
}
|
||||
|
||||
describe('PointCloudModelAdapter', () => {
|
||||
beforeEach(() => {
|
||||
mockSettingGet.mockReset()
|
||||
})
|
||||
|
||||
describe('identity', () => {
|
||||
it('handles the ply extension', () => {
|
||||
const adapter = new PointCloudModelAdapter()
|
||||
expect([...adapter.extensions]).toEqual(['ply'])
|
||||
})
|
||||
|
||||
it('identifies as pointCloud with rebuild + gizmo/fit disabled', () => {
|
||||
const adapter = new PointCloudModelAdapter()
|
||||
expect(adapter.kind).toBe('pointCloud')
|
||||
expect(adapter.capabilities.fitToViewer).toBe(false)
|
||||
expect(adapter.capabilities.requiresMaterialRebuild).toBe(true)
|
||||
expect(adapter.capabilities.gizmoTransform).toBe(false)
|
||||
expect(adapter.capabilities.lighting).toBe(true)
|
||||
expect(adapter.capabilities.exportable).toBe(true)
|
||||
expect([...adapter.capabilities.materialModes]).toEqual([
|
||||
'original',
|
||||
'pointCloud',
|
||||
'normal',
|
||||
'wireframe'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('load', () => {
|
||||
beforeEach(() => {
|
||||
mockSettingGet.mockReturnValue('three')
|
||||
vi.spyOn(ModelAdapterModule, 'fetchModelData').mockResolvedValue(
|
||||
new ArrayBuffer(0)
|
||||
)
|
||||
})
|
||||
|
||||
it('returns a Group containing a Mesh for non-pointCloud modes', async () => {
|
||||
const adapter = new PointCloudModelAdapter()
|
||||
const ctx = makeContext('original')
|
||||
|
||||
const result = await adapter.load(ctx, '/api/view?', 'cloud.ply')
|
||||
|
||||
expect(result).toBeInstanceOf(THREE.Group)
|
||||
const child = result!.children[0]
|
||||
expect(child).toBeInstanceOf(THREE.Mesh)
|
||||
expect(ctx.setOriginalModel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('returns a Group containing Points when materialMode is pointCloud', async () => {
|
||||
const adapter = new PointCloudModelAdapter()
|
||||
const ctx = makeContext('pointCloud')
|
||||
|
||||
const result = await adapter.load(ctx, '/api/view?', 'cloud.ply')
|
||||
|
||||
expect(result).toBeInstanceOf(THREE.Group)
|
||||
const child = result!.children[0]
|
||||
expect(child).toBeInstanceOf(THREE.Points)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,120 +0,0 @@
|
||||
import * as THREE from 'three'
|
||||
import { PLYLoader } from 'three/examples/jsm/loaders/PLYLoader'
|
||||
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { isPLYAsciiFormat } from '@/scripts/metadata/ply'
|
||||
|
||||
import { fetchModelData } from './ModelAdapter'
|
||||
import type {
|
||||
ModelAdapter,
|
||||
ModelAdapterCapabilities,
|
||||
ModelLoadContext
|
||||
} from './ModelAdapter'
|
||||
import { FastPLYLoader } from './loader/FastPLYLoader'
|
||||
|
||||
export function getPLYEngine(): string {
|
||||
return useSettingStore().get('Comfy.Load3D.PLYEngine') as string
|
||||
}
|
||||
|
||||
export class PointCloudModelAdapter implements ModelAdapter {
|
||||
readonly kind = 'pointCloud' as const
|
||||
readonly extensions = ['ply'] as const
|
||||
readonly capabilities: ModelAdapterCapabilities = {
|
||||
fitToViewer: false,
|
||||
requiresMaterialRebuild: true,
|
||||
gizmoTransform: false,
|
||||
lighting: true,
|
||||
exportable: true,
|
||||
materialModes: ['original', 'pointCloud', 'normal', 'wireframe'],
|
||||
fitTargetSize: 5
|
||||
}
|
||||
|
||||
private readonly plyLoader = new PLYLoader()
|
||||
private readonly fastPlyLoader = new FastPLYLoader()
|
||||
|
||||
async load(
|
||||
ctx: ModelLoadContext,
|
||||
path: string,
|
||||
filename: string
|
||||
): Promise<THREE.Object3D | null> {
|
||||
const arrayBuffer = await fetchModelData(path, filename)
|
||||
const isASCII = isPLYAsciiFormat(arrayBuffer)
|
||||
|
||||
const plyGeometry =
|
||||
isASCII && getPLYEngine() === 'fastply'
|
||||
? this.fastPlyLoader.parse(arrayBuffer)
|
||||
: this.plyLoader.parse(arrayBuffer)
|
||||
|
||||
ctx.setOriginalModel(plyGeometry)
|
||||
plyGeometry.computeVertexNormals()
|
||||
|
||||
const hasVertexColors = plyGeometry.attributes.color !== undefined
|
||||
|
||||
if (ctx.materialMode === 'pointCloud') {
|
||||
return buildPointsGroup(ctx, plyGeometry, hasVertexColors)
|
||||
}
|
||||
|
||||
return buildMeshGroup(ctx, plyGeometry, hasVertexColors)
|
||||
}
|
||||
}
|
||||
|
||||
function buildPointsGroup(
|
||||
ctx: ModelLoadContext,
|
||||
geometry: THREE.BufferGeometry,
|
||||
hasVertexColors: boolean
|
||||
): THREE.Group {
|
||||
geometry.computeBoundingSphere()
|
||||
if (geometry.boundingSphere) {
|
||||
const { center, radius } = geometry.boundingSphere
|
||||
geometry.translate(-center.x, -center.y, -center.z)
|
||||
if (radius > 0) {
|
||||
const scale = 1.0 / radius
|
||||
geometry.scale(scale, scale, scale)
|
||||
}
|
||||
}
|
||||
|
||||
const pointMaterial = hasVertexColors
|
||||
? new THREE.PointsMaterial({
|
||||
size: 0.005,
|
||||
vertexColors: true,
|
||||
sizeAttenuation: true
|
||||
})
|
||||
: new THREE.PointsMaterial({
|
||||
size: 0.005,
|
||||
color: 0xcccccc,
|
||||
sizeAttenuation: true
|
||||
})
|
||||
|
||||
const points = new THREE.Points(geometry, pointMaterial)
|
||||
ctx.registerOriginalMaterial(points as unknown as THREE.Mesh, pointMaterial)
|
||||
|
||||
const group = new THREE.Group()
|
||||
group.add(points)
|
||||
return group
|
||||
}
|
||||
|
||||
function buildMeshGroup(
|
||||
ctx: ModelLoadContext,
|
||||
geometry: THREE.BufferGeometry,
|
||||
hasVertexColors: boolean
|
||||
): THREE.Group {
|
||||
const material = hasVertexColors
|
||||
? new THREE.MeshStandardMaterial({
|
||||
vertexColors: true,
|
||||
metalness: 0.0,
|
||||
roughness: 0.5,
|
||||
side: THREE.DoubleSide
|
||||
})
|
||||
: ctx.standardMaterial.clone()
|
||||
|
||||
if (!hasVertexColors && material instanceof THREE.MeshStandardMaterial) {
|
||||
material.side = THREE.DoubleSide
|
||||
}
|
||||
|
||||
const mesh = new THREE.Mesh(geometry, material)
|
||||
ctx.registerOriginalMaterial(mesh, material)
|
||||
|
||||
const group = new THREE.Group()
|
||||
group.add(mesh)
|
||||
return group
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import * as THREE from 'three'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ModelLoadContext } from './ModelAdapter'
|
||||
import * as ModelAdapterModule from './ModelAdapter'
|
||||
import { SplatModelAdapter } from './SplatModelAdapter'
|
||||
|
||||
const { splatMeshCtor } = vi.hoisted(() => ({
|
||||
splatMeshCtor: vi.fn<(opts: { fileBytes: ArrayBuffer }) => void>()
|
||||
}))
|
||||
|
||||
vi.mock('@sparkjsdev/spark', async () => {
|
||||
const three = await import('three')
|
||||
return {
|
||||
SplatMesh: class extends three.Object3D {
|
||||
constructor(opts: { fileBytes: ArrayBuffer }) {
|
||||
super()
|
||||
splatMeshCtor(opts)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function makeContext(): ModelLoadContext {
|
||||
return {
|
||||
setOriginalModel: vi.fn(),
|
||||
registerOriginalMaterial: vi.fn(),
|
||||
standardMaterial: new THREE.MeshStandardMaterial(),
|
||||
materialMode: 'original'
|
||||
}
|
||||
}
|
||||
|
||||
describe('SplatModelAdapter', () => {
|
||||
beforeEach(() => {
|
||||
splatMeshCtor.mockReset()
|
||||
})
|
||||
|
||||
it('exposes splat capabilities on the adapter', () => {
|
||||
const adapter = new SplatModelAdapter()
|
||||
expect(adapter.kind).toBe('splat')
|
||||
expect(adapter.capabilities.lighting).toBe(false)
|
||||
expect(adapter.capabilities.exportable).toBe(false)
|
||||
expect([...adapter.capabilities.materialModes]).toEqual([])
|
||||
})
|
||||
|
||||
it('handles the Gaussian splat extensions', () => {
|
||||
const adapter = new SplatModelAdapter()
|
||||
expect([...adapter.extensions]).toEqual(['spz', 'splat', 'ksplat'])
|
||||
})
|
||||
|
||||
it('fetches the file, builds a SplatMesh, and wraps it in a Group', async () => {
|
||||
const buf = new ArrayBuffer(128)
|
||||
vi.spyOn(ModelAdapterModule, 'fetchModelData').mockResolvedValue(buf)
|
||||
|
||||
const adapter = new SplatModelAdapter()
|
||||
const ctx = makeContext()
|
||||
|
||||
const result = await adapter.load(ctx, '/api/view?', 'scene.splat')
|
||||
|
||||
expect(ModelAdapterModule.fetchModelData).toHaveBeenCalledWith(
|
||||
'/api/view?',
|
||||
'scene.splat'
|
||||
)
|
||||
expect(splatMeshCtor).toHaveBeenCalledWith({ fileBytes: buf })
|
||||
expect(result).toBeInstanceOf(THREE.Group)
|
||||
expect(result.children).toHaveLength(1)
|
||||
|
||||
expect(ctx.setOriginalModel).toHaveBeenCalledTimes(1)
|
||||
expect(ctx.setOriginalModel).toHaveBeenCalledWith(result.children[0])
|
||||
})
|
||||
|
||||
it('propagates fetch errors', async () => {
|
||||
vi.spyOn(ModelAdapterModule, 'fetchModelData').mockRejectedValue(
|
||||
new Error('Failed to fetch model: 500')
|
||||
)
|
||||
|
||||
const adapter = new SplatModelAdapter()
|
||||
await expect(
|
||||
adapter.load(makeContext(), '/api/view?', 'scene.splat')
|
||||
).rejects.toThrow('Failed to fetch model: 500')
|
||||
})
|
||||
})
|
||||
@@ -1,38 +0,0 @@
|
||||
import { SplatMesh } from '@sparkjsdev/spark'
|
||||
import * as THREE from 'three'
|
||||
|
||||
import { fetchModelData } from './ModelAdapter'
|
||||
import type {
|
||||
ModelAdapter,
|
||||
ModelAdapterCapabilities,
|
||||
ModelLoadContext
|
||||
} from './ModelAdapter'
|
||||
|
||||
export class SplatModelAdapter implements ModelAdapter {
|
||||
readonly kind = 'splat' as const
|
||||
readonly extensions = ['spz', 'splat', 'ksplat'] as const
|
||||
readonly capabilities: ModelAdapterCapabilities = {
|
||||
fitToViewer: false,
|
||||
requiresMaterialRebuild: false,
|
||||
gizmoTransform: false,
|
||||
lighting: false,
|
||||
exportable: false,
|
||||
materialModes: [],
|
||||
fitTargetSize: 5
|
||||
}
|
||||
|
||||
async load(
|
||||
ctx: ModelLoadContext,
|
||||
path: string,
|
||||
filename: string
|
||||
): Promise<THREE.Object3D> {
|
||||
const arrayBuffer = await fetchModelData(path, filename)
|
||||
|
||||
const splatMesh = new SplatMesh({ fileBytes: arrayBuffer })
|
||||
ctx.setOriginalModel(splatMesh)
|
||||
|
||||
const splatGroup = new THREE.Group()
|
||||
splatGroup.add(splatMesh)
|
||||
return splatGroup
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,11 @@
|
||||
import type * as THREE from 'three'
|
||||
import type { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
|
||||
import type { ViewHelper } from 'three/examples/jsm/helpers/ViewHelper'
|
||||
import type { GLTF } from 'three/examples/jsm/loaders/GLTFLoader'
|
||||
import type { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader'
|
||||
import type { GLTF, GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
|
||||
import type { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader'
|
||||
import type { STLLoader } from 'three/examples/jsm/loaders/STLLoader'
|
||||
import type { OBJLoader2Parallel } from 'wwobjloader2'
|
||||
|
||||
export type MaterialMode =
|
||||
| 'original'
|
||||
@@ -199,6 +203,12 @@ export interface ModelManagerInterface {
|
||||
}
|
||||
|
||||
export interface LoaderManagerInterface {
|
||||
gltfLoader: GLTFLoader
|
||||
objLoader: OBJLoader2Parallel
|
||||
mtlLoader: MTLLoader
|
||||
fbxLoader: FBXLoader
|
||||
stlLoader: STLLoader
|
||||
|
||||
init(): void
|
||||
dispose(): void
|
||||
loadModel(url: string, originalFileName?: string): Promise<void>
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { attachContextMenuGuard } from './load3dContextMenuGuard'
|
||||
|
||||
function rightMouse(type: string, x: number, y: number, buttons = 2) {
|
||||
const event = new MouseEvent(type, {
|
||||
button: 2,
|
||||
buttons,
|
||||
clientX: x,
|
||||
clientY: y,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
return event
|
||||
}
|
||||
|
||||
describe('attachContextMenuGuard', () => {
|
||||
let target: HTMLElement
|
||||
let onMenu: ReturnType<typeof vi.fn<(event: MouseEvent) => void>>
|
||||
let dispose: () => void
|
||||
|
||||
beforeEach(() => {
|
||||
target = document.createElement('div')
|
||||
document.body.appendChild(target)
|
||||
onMenu = vi.fn<(event: MouseEvent) => void>()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
dispose?.()
|
||||
target.remove()
|
||||
})
|
||||
|
||||
it('invokes onMenu for a right-click without drag movement', () => {
|
||||
dispose = attachContextMenuGuard(target, onMenu)
|
||||
|
||||
target.dispatchEvent(rightMouse('mousedown', 100, 100))
|
||||
target.dispatchEvent(rightMouse('contextmenu', 100, 100))
|
||||
|
||||
expect(onMenu).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('preventDefault is called on the contextmenu event when menu fires', () => {
|
||||
dispose = attachContextMenuGuard(target, onMenu)
|
||||
|
||||
target.dispatchEvent(rightMouse('mousedown', 0, 0))
|
||||
const contextEvent = rightMouse('contextmenu', 0, 0)
|
||||
target.dispatchEvent(contextEvent)
|
||||
|
||||
expect(contextEvent.defaultPrevented).toBe(true)
|
||||
})
|
||||
|
||||
it('suppresses onMenu when the mouse moved past the drag threshold', () => {
|
||||
dispose = attachContextMenuGuard(target, onMenu, { dragThreshold: 5 })
|
||||
|
||||
target.dispatchEvent(rightMouse('mousedown', 100, 100))
|
||||
target.dispatchEvent(rightMouse('mousemove', 120, 120))
|
||||
target.dispatchEvent(rightMouse('contextmenu', 120, 120))
|
||||
|
||||
expect(onMenu).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still fires onMenu when the mouse moved within the drag threshold', () => {
|
||||
dispose = attachContextMenuGuard(target, onMenu, { dragThreshold: 10 })
|
||||
|
||||
target.dispatchEvent(rightMouse('mousedown', 100, 100))
|
||||
target.dispatchEvent(rightMouse('mousemove', 103, 104))
|
||||
target.dispatchEvent(rightMouse('contextmenu', 103, 104))
|
||||
|
||||
expect(onMenu).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('detects a drag from start to contextmenu even without mousemove events', () => {
|
||||
dispose = attachContextMenuGuard(target, onMenu, { dragThreshold: 5 })
|
||||
|
||||
target.dispatchEvent(rightMouse('mousedown', 100, 100))
|
||||
target.dispatchEvent(rightMouse('contextmenu', 200, 200))
|
||||
|
||||
expect(onMenu).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resets drag state between right-clicks', () => {
|
||||
dispose = attachContextMenuGuard(target, onMenu, { dragThreshold: 5 })
|
||||
|
||||
target.dispatchEvent(rightMouse('mousedown', 100, 100))
|
||||
target.dispatchEvent(rightMouse('mousemove', 200, 200))
|
||||
target.dispatchEvent(rightMouse('contextmenu', 200, 200))
|
||||
expect(onMenu).not.toHaveBeenCalled()
|
||||
|
||||
target.dispatchEvent(rightMouse('mousedown', 50, 50))
|
||||
target.dispatchEvent(rightMouse('contextmenu', 50, 50))
|
||||
expect(onMenu).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('ignores onMenu when isDisabled returns true', () => {
|
||||
let disabled = true
|
||||
dispose = attachContextMenuGuard(target, onMenu, {
|
||||
isDisabled: () => disabled
|
||||
})
|
||||
|
||||
target.dispatchEvent(rightMouse('mousedown', 10, 10))
|
||||
target.dispatchEvent(rightMouse('contextmenu', 10, 10))
|
||||
expect(onMenu).not.toHaveBeenCalled()
|
||||
|
||||
disabled = false
|
||||
target.dispatchEvent(rightMouse('mousedown', 10, 10))
|
||||
target.dispatchEvent(rightMouse('contextmenu', 10, 10))
|
||||
expect(onMenu).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('stops listening after dispose', () => {
|
||||
dispose = attachContextMenuGuard(target, onMenu)
|
||||
dispose()
|
||||
|
||||
target.dispatchEvent(rightMouse('mousedown', 10, 10))
|
||||
target.dispatchEvent(rightMouse('contextmenu', 10, 10))
|
||||
|
||||
expect(onMenu).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores mousemove events without the right button held', () => {
|
||||
dispose = attachContextMenuGuard(target, onMenu, { dragThreshold: 5 })
|
||||
|
||||
target.dispatchEvent(rightMouse('mousedown', 100, 100))
|
||||
target.dispatchEvent(rightMouse('mousemove', 200, 200, 0))
|
||||
target.dispatchEvent(rightMouse('contextmenu', 100, 100))
|
||||
|
||||
expect(onMenu).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -1,72 +0,0 @@
|
||||
import { exceedsClickThreshold } from '@/composables/useClickDragGuard'
|
||||
|
||||
type ContextMenuGuardOptions = {
|
||||
isDisabled?: () => boolean
|
||||
dragThreshold?: number
|
||||
}
|
||||
|
||||
export function attachContextMenuGuard(
|
||||
target: HTMLElement,
|
||||
onMenu: (event: MouseEvent) => void,
|
||||
{ isDisabled = () => false, dragThreshold = 5 }: ContextMenuGuardOptions = {}
|
||||
): () => void {
|
||||
const abort = new AbortController()
|
||||
const { signal } = abort
|
||||
|
||||
let start = { x: 0, y: 0 }
|
||||
let moved = false
|
||||
|
||||
target.addEventListener(
|
||||
'mousedown',
|
||||
(e) => {
|
||||
if (e.button === 2) {
|
||||
start = { x: e.clientX, y: e.clientY }
|
||||
moved = false
|
||||
}
|
||||
},
|
||||
{ signal }
|
||||
)
|
||||
|
||||
target.addEventListener(
|
||||
'mousemove',
|
||||
(e) => {
|
||||
if (
|
||||
e.buttons === 2 &&
|
||||
exceedsClickThreshold(
|
||||
start,
|
||||
{ x: e.clientX, y: e.clientY },
|
||||
dragThreshold
|
||||
)
|
||||
) {
|
||||
moved = true
|
||||
}
|
||||
},
|
||||
{ signal }
|
||||
)
|
||||
|
||||
target.addEventListener(
|
||||
'contextmenu',
|
||||
(e) => {
|
||||
if (isDisabled()) return
|
||||
|
||||
const wasDragging =
|
||||
moved ||
|
||||
exceedsClickThreshold(
|
||||
start,
|
||||
{ x: e.clientX, y: e.clientY },
|
||||
dragThreshold
|
||||
)
|
||||
|
||||
moved = false
|
||||
|
||||
if (wasDragging) return
|
||||
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onMenu(e)
|
||||
},
|
||||
{ signal }
|
||||
)
|
||||
|
||||
return () => abort.abort()
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { startRenderLoop } from './load3dRenderLoop'
|
||||
|
||||
describe('startRenderLoop', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('runs tick on each frame while isActive returns true', () => {
|
||||
const tick = vi.fn()
|
||||
const handle = startRenderLoop({ tick, isActive: () => true })
|
||||
|
||||
vi.advanceTimersToNextTimer()
|
||||
vi.advanceTimersToNextTimer()
|
||||
vi.advanceTimersToNextTimer()
|
||||
|
||||
expect(tick.mock.calls.length).toBeGreaterThanOrEqual(3)
|
||||
handle.stop()
|
||||
})
|
||||
|
||||
it('skips tick on frames where isActive returns false', () => {
|
||||
let active = false
|
||||
const tick = vi.fn()
|
||||
const handle = startRenderLoop({ tick, isActive: () => active })
|
||||
|
||||
vi.advanceTimersToNextTimer()
|
||||
vi.advanceTimersToNextTimer()
|
||||
expect(tick).not.toHaveBeenCalled()
|
||||
|
||||
active = true
|
||||
vi.advanceTimersToNextTimer()
|
||||
expect(tick).toHaveBeenCalledOnce()
|
||||
|
||||
handle.stop()
|
||||
})
|
||||
|
||||
it('stop halts further ticks', () => {
|
||||
const tick = vi.fn()
|
||||
const handle = startRenderLoop({ tick, isActive: () => true })
|
||||
|
||||
vi.advanceTimersToNextTimer()
|
||||
const callsBeforeStop = tick.mock.calls.length
|
||||
|
||||
handle.stop()
|
||||
vi.advanceTimersToNextTimer()
|
||||
vi.advanceTimersToNextTimer()
|
||||
|
||||
expect(tick.mock.calls.length).toBe(callsBeforeStop)
|
||||
})
|
||||
|
||||
it('is safe to call stop multiple times', () => {
|
||||
const handle = startRenderLoop({ tick: vi.fn(), isActive: () => true })
|
||||
|
||||
handle.stop()
|
||||
expect(() => handle.stop()).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -1,32 +0,0 @@
|
||||
type RenderLoopOptions = {
|
||||
tick: () => void
|
||||
isActive: () => boolean
|
||||
}
|
||||
|
||||
export type RenderLoopHandle = {
|
||||
stop: () => void
|
||||
}
|
||||
|
||||
export function startRenderLoop({
|
||||
tick,
|
||||
isActive
|
||||
}: RenderLoopOptions): RenderLoopHandle {
|
||||
let frameId: number | null = null
|
||||
|
||||
const loop = () => {
|
||||
frameId = requestAnimationFrame(loop)
|
||||
if (!isActive()) return
|
||||
tick()
|
||||
}
|
||||
|
||||
loop()
|
||||
|
||||
return {
|
||||
stop() {
|
||||
if (frameId !== null) {
|
||||
cancelAnimationFrame(frameId)
|
||||
frameId = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { computeLetterboxedViewport, isLoad3dActive } from './load3dViewport'
|
||||
import type { Load3dActivityFlags } from './load3dViewport'
|
||||
|
||||
describe('computeLetterboxedViewport', () => {
|
||||
it('pillarboxes when the container is wider than the target aspect', () => {
|
||||
const viewport = computeLetterboxedViewport({ width: 800, height: 400 }, 1)
|
||||
|
||||
expect(viewport).toEqual({
|
||||
offsetX: 200,
|
||||
offsetY: 0,
|
||||
width: 400,
|
||||
height: 400
|
||||
})
|
||||
})
|
||||
|
||||
it('letterboxes when the container is taller than the target aspect', () => {
|
||||
const viewport = computeLetterboxedViewport({ width: 400, height: 800 }, 1)
|
||||
|
||||
expect(viewport).toEqual({
|
||||
offsetX: 0,
|
||||
offsetY: 200,
|
||||
width: 400,
|
||||
height: 400
|
||||
})
|
||||
})
|
||||
|
||||
it('fills the container when aspect ratios match exactly', () => {
|
||||
const viewport = computeLetterboxedViewport(
|
||||
{ width: 1024, height: 768 },
|
||||
1024 / 768
|
||||
)
|
||||
|
||||
expect(viewport.offsetX).toBe(0)
|
||||
expect(viewport.offsetY).toBe(0)
|
||||
expect(viewport.width).toBe(1024)
|
||||
expect(viewport.height).toBe(768)
|
||||
})
|
||||
|
||||
it('handles a wide target aspect inside a square container', () => {
|
||||
const viewport = computeLetterboxedViewport(
|
||||
{ width: 600, height: 600 },
|
||||
16 / 9
|
||||
)
|
||||
|
||||
expect(viewport.offsetX).toBe(0)
|
||||
expect(viewport.width).toBe(600)
|
||||
expect(viewport.height).toBeCloseTo(337.5)
|
||||
expect(viewport.offsetY).toBeCloseTo((600 - 337.5) / 2)
|
||||
})
|
||||
|
||||
it('handles a tall target aspect inside a square container', () => {
|
||||
const viewport = computeLetterboxedViewport(
|
||||
{ width: 600, height: 600 },
|
||||
9 / 16
|
||||
)
|
||||
|
||||
expect(viewport.offsetY).toBe(0)
|
||||
expect(viewport.height).toBe(600)
|
||||
expect(viewport.width).toBeCloseTo(337.5)
|
||||
expect(viewport.offsetX).toBeCloseTo((600 - 337.5) / 2)
|
||||
})
|
||||
|
||||
it('preserves the target aspect ratio in the returned rect', () => {
|
||||
const target = 16 / 9
|
||||
const wide = computeLetterboxedViewport(
|
||||
{ width: 1920, height: 500 },
|
||||
target
|
||||
)
|
||||
const tall = computeLetterboxedViewport(
|
||||
{ width: 500, height: 1920 },
|
||||
target
|
||||
)
|
||||
|
||||
expect(wide.width / wide.height).toBeCloseTo(target)
|
||||
expect(tall.width / tall.height).toBeCloseTo(target)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isLoad3dActive', () => {
|
||||
const idle: Load3dActivityFlags = {
|
||||
mouseOnNode: false,
|
||||
mouseOnScene: false,
|
||||
mouseOnViewer: false,
|
||||
recording: false,
|
||||
initialRenderDone: true,
|
||||
animationPlaying: false
|
||||
}
|
||||
|
||||
it('is inactive once the first frame is rendered with nothing happening', () => {
|
||||
expect(isLoad3dActive(idle)).toBe(false)
|
||||
})
|
||||
|
||||
it('is active before the first frame renders', () => {
|
||||
expect(isLoad3dActive({ ...idle, initialRenderDone: false })).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['mouseOnNode'],
|
||||
['mouseOnScene'],
|
||||
['mouseOnViewer'],
|
||||
['recording'],
|
||||
['animationPlaying']
|
||||
] as const)('is active when %s is true', (flag) => {
|
||||
expect(isLoad3dActive({ ...idle, [flag]: true })).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,55 +0,0 @@
|
||||
type Size = { width: number; height: number }
|
||||
|
||||
type LetterboxedViewport = {
|
||||
offsetX: number
|
||||
offsetY: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export function computeLetterboxedViewport(
|
||||
container: Size,
|
||||
targetAspectRatio: number
|
||||
): LetterboxedViewport {
|
||||
const containerAspectRatio = container.width / container.height
|
||||
|
||||
if (containerAspectRatio > targetAspectRatio) {
|
||||
const height = container.height
|
||||
const width = height * targetAspectRatio
|
||||
return {
|
||||
offsetX: (container.width - width) / 2,
|
||||
offsetY: 0,
|
||||
width,
|
||||
height
|
||||
}
|
||||
}
|
||||
|
||||
const width = container.width
|
||||
const height = width / targetAspectRatio
|
||||
return {
|
||||
offsetX: 0,
|
||||
offsetY: (container.height - height) / 2,
|
||||
width,
|
||||
height
|
||||
}
|
||||
}
|
||||
|
||||
export type Load3dActivityFlags = {
|
||||
mouseOnNode: boolean
|
||||
mouseOnScene: boolean
|
||||
mouseOnViewer: boolean
|
||||
recording: boolean
|
||||
initialRenderDone: boolean
|
||||
animationPlaying: boolean
|
||||
}
|
||||
|
||||
export function isLoad3dActive(flags: Load3dActivityFlags): boolean {
|
||||
return (
|
||||
flags.mouseOnNode ||
|
||||
flags.mouseOnScene ||
|
||||
flags.mouseOnViewer ||
|
||||
flags.recording ||
|
||||
!flags.initialRenderDone ||
|
||||
flags.animationPlaying
|
||||
)
|
||||
}
|
||||
@@ -335,7 +335,6 @@
|
||||
},
|
||||
"breadcrumbsMenu": {
|
||||
"app": "التطبيق",
|
||||
"blueprint": "المخطط",
|
||||
"clearWorkflow": "مسح سير العمل",
|
||||
"deleteBlueprint": "حذف المخطط",
|
||||
"deleteWorkflow": "حذف سير العمل",
|
||||
@@ -919,10 +918,6 @@
|
||||
"showSwapNodes": "عرض العقد البديلة",
|
||||
"swapNodes": "يمكن استبدال بعض العقد ببدائل"
|
||||
},
|
||||
"errorPanelSurvey": {
|
||||
"ctaButton": "أعطِ ملاحظاتك",
|
||||
"ctaText": "ما رأيك في لوحة الأخطاء الجديدة؟"
|
||||
},
|
||||
"essentials": {
|
||||
"batchImage": "معالجة صور دفعة واحدة",
|
||||
"canny": "كانّي",
|
||||
|
||||
@@ -2734,7 +2734,6 @@
|
||||
"noReleaseNotes": "No release notes available."
|
||||
},
|
||||
"breadcrumbsMenu": {
|
||||
"blueprint": "Blueprint",
|
||||
"duplicate": "Duplicate",
|
||||
"enterAppMode": "Enter app mode",
|
||||
"exitAppMode": "Exit app mode",
|
||||
|
||||
@@ -11743,8 +11743,8 @@
|
||||
}
|
||||
},
|
||||
"OpenAIVideoSora2": {
|
||||
"display_name": "OpenAI Sora - Video (Deprecated)",
|
||||
"description": "OpenAI video and audio generation.\n\nDEPRECATION NOTICE: OpenAI will stop serving the Sora v2 API in September 2026. This node will be removed from ComfyUI at that time.",
|
||||
"display_name": "OpenAI Sora - Video",
|
||||
"description": "OpenAI video and audio generation.",
|
||||
"inputs": {
|
||||
"model": {
|
||||
"name": "model"
|
||||
|
||||
@@ -335,7 +335,6 @@
|
||||
},
|
||||
"breadcrumbsMenu": {
|
||||
"app": "Aplicación",
|
||||
"blueprint": "Plano",
|
||||
"clearWorkflow": "Limpiar flujo de trabajo",
|
||||
"deleteBlueprint": "Eliminar Plano",
|
||||
"deleteWorkflow": "Eliminar flujo de trabajo",
|
||||
@@ -919,10 +918,6 @@
|
||||
"showSwapNodes": "Mostrar nodos intercambiables",
|
||||
"swapNodes": "Algunos nodos pueden ser reemplazados por alternativas"
|
||||
},
|
||||
"errorPanelSurvey": {
|
||||
"ctaButton": "Dar opinión",
|
||||
"ctaText": "¿Qué te parece el nuevo panel de errores?"
|
||||
},
|
||||
"essentials": {
|
||||
"batchImage": "Procesar imágenes por lotes",
|
||||
"canny": "Canny",
|
||||
|
||||
@@ -335,7 +335,6 @@
|
||||
},
|
||||
"breadcrumbsMenu": {
|
||||
"app": "برنامه",
|
||||
"blueprint": "نقشه راه",
|
||||
"clearWorkflow": "پاکسازی workflow",
|
||||
"deleteBlueprint": "حذف blueprint",
|
||||
"deleteWorkflow": "حذف workflow",
|
||||
@@ -919,10 +918,6 @@
|
||||
"showSwapNodes": "نمایش نودهای قابل جایگزینی",
|
||||
"swapNodes": "برخی از نودها را میتوان با گزینههای جایگزین تعویض کرد"
|
||||
},
|
||||
"errorPanelSurvey": {
|
||||
"ctaButton": "ارسال بازخورد",
|
||||
"ctaText": "نظر شما درباره پنل خطا جدید چیست؟"
|
||||
},
|
||||
"essentials": {
|
||||
"batchImage": "پردازش دستهای تصویر",
|
||||
"canny": "لبهیابی Canny",
|
||||
|
||||
@@ -335,7 +335,6 @@
|
||||
},
|
||||
"breadcrumbsMenu": {
|
||||
"app": "Application",
|
||||
"blueprint": "Plan",
|
||||
"clearWorkflow": "Effacer le workflow",
|
||||
"deleteBlueprint": "Supprimer le plan",
|
||||
"deleteWorkflow": "Supprimer le workflow",
|
||||
@@ -919,10 +918,6 @@
|
||||
"showSwapNodes": "Afficher les nœuds de remplacement",
|
||||
"swapNodes": "Certains nœuds peuvent être remplacés par des alternatives"
|
||||
},
|
||||
"errorPanelSurvey": {
|
||||
"ctaButton": "Donner votre avis",
|
||||
"ctaText": "Que pensez-vous du nouveau panneau d’erreurs ?"
|
||||
},
|
||||
"essentials": {
|
||||
"batchImage": "Traitement par lot d'images",
|
||||
"canny": "Canny",
|
||||
|
||||
@@ -335,7 +335,6 @@
|
||||
},
|
||||
"breadcrumbsMenu": {
|
||||
"app": "アプリ",
|
||||
"blueprint": "ブループリント",
|
||||
"clearWorkflow": "ワークフローをクリア",
|
||||
"deleteBlueprint": "ブループリントを削除",
|
||||
"deleteWorkflow": "ワークフローを削除",
|
||||
@@ -919,10 +918,6 @@
|
||||
"showSwapNodes": "代替可能なノードを表示",
|
||||
"swapNodes": "いくつかのノードは代替可能です"
|
||||
},
|
||||
"errorPanelSurvey": {
|
||||
"ctaButton": "フィードバックを送る",
|
||||
"ctaText": "新しいエラーパネルはいかがですか?"
|
||||
},
|
||||
"essentials": {
|
||||
"batchImage": "バッチ画像処理",
|
||||
"canny": "Canny",
|
||||
|
||||
@@ -335,7 +335,6 @@
|
||||
},
|
||||
"breadcrumbsMenu": {
|
||||
"app": "앱",
|
||||
"blueprint": "블루프린트",
|
||||
"clearWorkflow": "워크플로 내용 지우기",
|
||||
"deleteBlueprint": "블루프린트 삭제",
|
||||
"deleteWorkflow": "워크플로 삭제",
|
||||
@@ -919,10 +918,6 @@
|
||||
"showSwapNodes": "교체 가능한 노드 표시",
|
||||
"swapNodes": "일부 노드는 대체 가능한 노드로 교체할 수 있습니다"
|
||||
},
|
||||
"errorPanelSurvey": {
|
||||
"ctaButton": "피드백 남기기",
|
||||
"ctaText": "새로운 오류 패널은 어떠신가요?"
|
||||
},
|
||||
"essentials": {
|
||||
"batchImage": "이미지 일괄 처리",
|
||||
"canny": "Canny",
|
||||
|
||||
@@ -335,7 +335,6 @@
|
||||
},
|
||||
"breadcrumbsMenu": {
|
||||
"app": "App",
|
||||
"blueprint": "Blueprint",
|
||||
"clearWorkflow": "Limpar Fluxo de Trabalho",
|
||||
"deleteBlueprint": "Excluir Blueprint",
|
||||
"deleteWorkflow": "Excluir Fluxo de Trabalho",
|
||||
@@ -919,10 +918,6 @@
|
||||
"showSwapNodes": "Mostrar nós alternativos",
|
||||
"swapNodes": "Alguns nós podem ser substituídos por alternativas"
|
||||
},
|
||||
"errorPanelSurvey": {
|
||||
"ctaButton": "Enviar feedback",
|
||||
"ctaText": "O que achou do novo painel de erros?"
|
||||
},
|
||||
"essentials": {
|
||||
"batchImage": "Imagem em lote",
|
||||
"canny": "Canny",
|
||||
|
||||
@@ -335,7 +335,6 @@
|
||||
},
|
||||
"breadcrumbsMenu": {
|
||||
"app": "Приложение",
|
||||
"blueprint": "Чертёж",
|
||||
"clearWorkflow": "Очистить рабочий процесс",
|
||||
"deleteBlueprint": "Удалить схему",
|
||||
"deleteWorkflow": "Удалить рабочий процесс",
|
||||
@@ -919,10 +918,6 @@
|
||||
"showSwapNodes": "Показать заменяемые узлы",
|
||||
"swapNodes": "Некоторые узлы можно заменить альтернативами"
|
||||
},
|
||||
"errorPanelSurvey": {
|
||||
"ctaButton": "Оставить отзыв",
|
||||
"ctaText": "Как вам новая панель ошибок?"
|
||||
},
|
||||
"essentials": {
|
||||
"batchImage": "Пакетная обработка изображений",
|
||||
"canny": "Canny",
|
||||
|
||||
@@ -335,7 +335,6 @@
|
||||
},
|
||||
"breadcrumbsMenu": {
|
||||
"app": "Uygulama",
|
||||
"blueprint": "Plan",
|
||||
"clearWorkflow": "İş Akışını Temizle",
|
||||
"deleteBlueprint": "Taslağı Sil",
|
||||
"deleteWorkflow": "İş Akışını Sil",
|
||||
@@ -919,10 +918,6 @@
|
||||
"showSwapNodes": "Değiştirilebilir düğümleri göster",
|
||||
"swapNodes": "Bazı düğümler alternatiflerle değiştirilebilir"
|
||||
},
|
||||
"errorPanelSurvey": {
|
||||
"ctaButton": "Geri bildirim ver",
|
||||
"ctaText": "Yeni hata paneli nasıl?"
|
||||
},
|
||||
"essentials": {
|
||||
"batchImage": "Toplu Görüntü",
|
||||
"canny": "Canny",
|
||||
|
||||
@@ -335,7 +335,6 @@
|
||||
},
|
||||
"breadcrumbsMenu": {
|
||||
"app": "應用程式",
|
||||
"blueprint": "藍圖",
|
||||
"clearWorkflow": "清除工作流程",
|
||||
"deleteBlueprint": "刪除藍圖",
|
||||
"deleteWorkflow": "刪除工作流程",
|
||||
@@ -919,10 +918,6 @@
|
||||
"showSwapNodes": "顯示可替換的節點",
|
||||
"swapNodes": "有些節點可以用其他選項替換"
|
||||
},
|
||||
"errorPanelSurvey": {
|
||||
"ctaButton": "提供回饋",
|
||||
"ctaText": "新的錯誤面板感覺如何?"
|
||||
},
|
||||
"essentials": {
|
||||
"batchImage": "批次圖片",
|
||||
"canny": "Canny 邊緣",
|
||||
|
||||
@@ -335,7 +335,6 @@
|
||||
},
|
||||
"breadcrumbsMenu": {
|
||||
"app": "应用",
|
||||
"blueprint": "蓝图",
|
||||
"clearWorkflow": "清除工作流",
|
||||
"deleteBlueprint": "删除蓝图",
|
||||
"deleteWorkflow": "删除工作流",
|
||||
@@ -919,10 +918,6 @@
|
||||
"showSwapNodes": "显示可替换节点",
|
||||
"swapNodes": "部分节点可以用替代项替换"
|
||||
},
|
||||
"errorPanelSurvey": {
|
||||
"ctaButton": "提交反馈",
|
||||
"ctaText": "新的错误面板感觉如何?"
|
||||
},
|
||||
"essentials": {
|
||||
"batchImage": "批量图像",
|
||||
"canny": "Canny",
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import AssetCard from '@/platform/assets/components/AssetCard.vue'
|
||||
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: () => 0
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/assetDownloadStore', () => ({
|
||||
useAssetDownloadStore: () => ({
|
||||
isDownloadedThisSession: () => false,
|
||||
acknowledgeAsset: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: () => ({
|
||||
closeDialog: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/assets/services/assetService', () => ({
|
||||
assetService: {
|
||||
deleteAsset: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/dialog/confirm/confirmDialog', () => ({
|
||||
showConfirmDialog: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@vueuse/core', async () => {
|
||||
const actual = await vi.importActual<Record<string, unknown>>('@vueuse/core')
|
||||
return {
|
||||
...actual,
|
||||
useImage: () => ({ isLoading: false, error: null })
|
||||
}
|
||||
})
|
||||
|
||||
const HASH = 'blake3:abc123def456'
|
||||
const ORIGINAL_FILENAME = 'sunset_photo.png'
|
||||
|
||||
function createDisplayAsset(
|
||||
overrides: Partial<AssetDisplayItem> = {}
|
||||
): AssetDisplayItem {
|
||||
return {
|
||||
id: 'asset-1',
|
||||
name: HASH,
|
||||
asset_hash: HASH,
|
||||
tags: ['input'],
|
||||
preview_url: '/preview.png',
|
||||
secondaryText: '',
|
||||
badges: [],
|
||||
stats: {},
|
||||
user_metadata: {},
|
||||
metadata: { filename: ORIGINAL_FILENAME },
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function renderCard(asset: AssetDisplayItem) {
|
||||
setActivePinia(createPinia())
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: {} },
|
||||
missingWarn: false,
|
||||
fallbackWarn: false
|
||||
})
|
||||
return render(AssetCard, {
|
||||
props: { asset, interactive: true },
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
AssetBadgeGroup: true,
|
||||
IconGroup: true,
|
||||
MoreButton: true,
|
||||
StatusBadge: true,
|
||||
Button: { template: '<button><slot /></button>' }
|
||||
},
|
||||
directives: {
|
||||
tooltip: {}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('AssetCard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('FE-228: filename rendering', () => {
|
||||
it('renders the human-readable filename instead of asset_hash when asset.name equals asset_hash', () => {
|
||||
const asset = createDisplayAsset()
|
||||
|
||||
renderCard(asset)
|
||||
|
||||
const heading = screen.getByRole('heading', { level: 3 })
|
||||
expect(heading).toHaveTextContent(ORIGINAL_FILENAME)
|
||||
expect(heading).not.toHaveTextContent(HASH)
|
||||
})
|
||||
|
||||
it('falls back to display_name when user_metadata.filename and metadata.filename are absent', () => {
|
||||
const asset = createDisplayAsset({
|
||||
metadata: undefined,
|
||||
user_metadata: undefined,
|
||||
display_name: ORIGINAL_FILENAME
|
||||
})
|
||||
|
||||
renderCard(asset)
|
||||
|
||||
const heading = screen.getByRole('heading', { level: 3 })
|
||||
expect(heading).toHaveTextContent(ORIGINAL_FILENAME)
|
||||
expect(heading).not.toHaveTextContent(HASH)
|
||||
})
|
||||
})
|
||||
|
||||
describe('preserves user-curated display name', () => {
|
||||
const CURATED_NAME = 'My Favorite SDXL LoRA'
|
||||
const MODEL_FILENAME = 'lora_v1_epoch4.safetensors'
|
||||
|
||||
it('renders the curated name (user_metadata.name) when it differs from the raw asset.name', () => {
|
||||
const asset = createDisplayAsset({
|
||||
id: 'model-1',
|
||||
name: MODEL_FILENAME,
|
||||
asset_hash: undefined,
|
||||
tags: ['models', 'loras'],
|
||||
user_metadata: { name: CURATED_NAME },
|
||||
metadata: { filename: MODEL_FILENAME }
|
||||
})
|
||||
|
||||
renderCard(asset)
|
||||
|
||||
const heading = screen.getByRole('heading', { level: 3 })
|
||||
expect(heading).toHaveTextContent(CURATED_NAME)
|
||||
expect(heading).not.toHaveTextContent(MODEL_FILENAME)
|
||||
})
|
||||
|
||||
it('ignores user_metadata.name that duplicates the hash and falls back to metadata.filename', () => {
|
||||
const asset = createDisplayAsset({
|
||||
name: HASH,
|
||||
asset_hash: HASH,
|
||||
user_metadata: { name: HASH },
|
||||
metadata: { filename: ORIGINAL_FILENAME }
|
||||
})
|
||||
|
||||
renderCard(asset)
|
||||
|
||||
const heading = screen.getByRole('heading', { level: 3 })
|
||||
expect(heading).toHaveTextContent(ORIGINAL_FILENAME)
|
||||
expect(heading).not.toHaveTextContent(HASH)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -143,7 +143,7 @@ import Button from '@/components/ui/button/Button.vue'
|
||||
import AssetBadgeGroup from '@/platform/assets/components/AssetBadgeGroup.vue'
|
||||
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
import { getAssetCardTitle } from '@/platform/assets/utils/assetMetadataUtils'
|
||||
import { getAssetDisplayName } from '@/platform/assets/utils/assetMetadataUtils'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
@@ -174,7 +174,7 @@ const dropdownMenuButton = useTemplateRef<InstanceType<typeof MoreButton>>(
|
||||
const titleId = useId()
|
||||
const descId = useId()
|
||||
|
||||
const displayName = computed(() => getAssetCardTitle(asset))
|
||||
const displayName = computed(() => getAssetDisplayName(asset))
|
||||
|
||||
const isNewlyImported = computed(() => isDownloadedThisSession(asset.id))
|
||||
|
||||
|
||||
@@ -2,11 +2,7 @@
|
||||
<div class="inline-flex items-center">
|
||||
<Popover>
|
||||
<template #button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
:aria-label="$t('assetBrowser.filterBy')"
|
||||
>
|
||||
<Button variant="secondary" size="icon">
|
||||
<i class="icon-[lucide--list-filter]" />
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import MediaAssetFilterMenu from '@/platform/assets/components/MediaAssetFilterMenu.vue'
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key
|
||||
})
|
||||
}))
|
||||
|
||||
function renderMenu(mediaTypeFilters: string[] = []) {
|
||||
const onUpdate = vi.fn()
|
||||
const utils = render(MediaAssetFilterMenu, {
|
||||
props: {
|
||||
mediaTypeFilters,
|
||||
'onUpdate:mediaTypeFilters': onUpdate
|
||||
},
|
||||
global: {
|
||||
mocks: {
|
||||
$t: (key: string) => key
|
||||
}
|
||||
}
|
||||
})
|
||||
return { ...utils, onUpdate, user: userEvent.setup() }
|
||||
}
|
||||
|
||||
const labelByType: Record<string, string> = {
|
||||
image: 'sideToolbar.mediaAssets.filterImage',
|
||||
video: 'sideToolbar.mediaAssets.filterVideo',
|
||||
audio: 'sideToolbar.mediaAssets.filterAudio',
|
||||
'3d': 'sideToolbar.mediaAssets.filter3D'
|
||||
}
|
||||
|
||||
function getCheckbox(type: keyof typeof labelByType): HTMLElement {
|
||||
return screen.getByRole('checkbox', { name: labelByType[type] })
|
||||
}
|
||||
|
||||
describe('MediaAssetFilterMenu', () => {
|
||||
it('renders all four media-type checkboxes', () => {
|
||||
renderMenu()
|
||||
|
||||
const checkboxes = screen.getAllByRole('checkbox')
|
||||
expect(checkboxes).toHaveLength(4)
|
||||
for (const type of Object.keys(labelByType)) {
|
||||
expect(getCheckbox(type)).toBeTruthy()
|
||||
}
|
||||
})
|
||||
|
||||
it('reflects checked state from the prop via aria-checked', () => {
|
||||
renderMenu(['image', '3d'])
|
||||
|
||||
expect(getCheckbox('image').getAttribute('aria-checked')).toBe('true')
|
||||
expect(getCheckbox('3d').getAttribute('aria-checked')).toBe('true')
|
||||
expect(getCheckbox('video').getAttribute('aria-checked')).toBe('false')
|
||||
expect(getCheckbox('audio').getAttribute('aria-checked')).toBe('false')
|
||||
})
|
||||
|
||||
it('emits an array containing the new type when an unchecked box is clicked', async () => {
|
||||
const { onUpdate, user } = renderMenu([])
|
||||
await user.click(getCheckbox('video'))
|
||||
|
||||
expect(onUpdate).toHaveBeenCalledTimes(1)
|
||||
expect(onUpdate).toHaveBeenCalledWith(['video'])
|
||||
})
|
||||
|
||||
it('emits an array without the type when a checked box is clicked again', async () => {
|
||||
const { onUpdate, user } = renderMenu(['image', 'audio'])
|
||||
await user.click(getCheckbox('audio'))
|
||||
|
||||
expect(onUpdate).toHaveBeenCalledWith(['image'])
|
||||
})
|
||||
|
||||
it('appends to the existing filter list rather than replacing it', async () => {
|
||||
const { onUpdate, user } = renderMenu(['image'])
|
||||
await user.click(getCheckbox('video'))
|
||||
|
||||
expect(onUpdate).toHaveBeenCalledWith(['image', 'video'])
|
||||
})
|
||||
|
||||
it('toggles via keyboard (Enter and Space)', async () => {
|
||||
const { onUpdate, user } = renderMenu([])
|
||||
|
||||
getCheckbox('image').focus()
|
||||
await user.keyboard('{Enter}')
|
||||
expect(onUpdate).toHaveBeenLastCalledWith(['image'])
|
||||
|
||||
getCheckbox('audio').focus()
|
||||
await user.keyboard(' ')
|
||||
expect(onUpdate).toHaveBeenLastCalledWith(['audio'])
|
||||
})
|
||||
})
|
||||
@@ -1,130 +0,0 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { defineComponent, ref } from 'vue'
|
||||
|
||||
import MediaAssetSettingsMenu from '@/platform/assets/components/MediaAssetSettingsMenu.vue'
|
||||
import type { SortBy } from '@/platform/assets/components/MediaAssetSettingsMenu.vue'
|
||||
|
||||
const KEYS = {
|
||||
list: 'sideToolbar.queueProgressOverlay.viewList',
|
||||
grid: 'sideToolbar.queueProgressOverlay.viewGrid',
|
||||
newest: 'sideToolbar.mediaAssets.sortNewestFirst',
|
||||
oldest: 'sideToolbar.mediaAssets.sortOldestFirst',
|
||||
longest: 'sideToolbar.mediaAssets.sortLongestFirst',
|
||||
fastest: 'sideToolbar.mediaAssets.sortFastestFirst'
|
||||
} as const
|
||||
|
||||
interface MountOptions {
|
||||
viewMode?: 'list' | 'grid'
|
||||
sortBy?: SortBy
|
||||
showSortOptions?: boolean
|
||||
showGenerationTimeSort?: boolean
|
||||
}
|
||||
|
||||
function mountWithModels(options: MountOptions = {}) {
|
||||
const viewMode = ref<'list' | 'grid'>(options.viewMode ?? 'list')
|
||||
const sortBy = ref<SortBy>(options.sortBy ?? 'newest')
|
||||
|
||||
const Host = defineComponent({
|
||||
components: { MediaAssetSettingsMenu },
|
||||
setup() {
|
||||
return {
|
||||
viewMode,
|
||||
sortBy,
|
||||
showSortOptions: options.showSortOptions ?? false,
|
||||
showGenerationTimeSort: options.showGenerationTimeSort ?? false
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<MediaAssetSettingsMenu
|
||||
v-model:viewMode="viewMode"
|
||||
v-model:sortBy="sortBy"
|
||||
:showSortOptions="showSortOptions"
|
||||
:showGenerationTimeSort="showGenerationTimeSort"
|
||||
/>
|
||||
`
|
||||
})
|
||||
|
||||
const utils = render(Host, {
|
||||
global: {
|
||||
mocks: {
|
||||
$t: (key: string) => key
|
||||
}
|
||||
}
|
||||
})
|
||||
return { ...utils, viewMode, sortBy, user: userEvent.setup() }
|
||||
}
|
||||
|
||||
function getButton(label: string): HTMLElement {
|
||||
return screen.getByRole('button', { name: label })
|
||||
}
|
||||
|
||||
describe('MediaAssetSettingsMenu', () => {
|
||||
describe('view-mode options (always visible)', () => {
|
||||
it('renders both list and grid view options', () => {
|
||||
mountWithModels()
|
||||
expect(getButton(KEYS.list)).toBeTruthy()
|
||||
expect(getButton(KEYS.grid)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('updates the v-model:viewMode when an option is clicked', async () => {
|
||||
const { viewMode, user } = mountWithModels({ viewMode: 'list' })
|
||||
await user.click(getButton(KEYS.grid))
|
||||
expect(viewMode.value).toBe('grid')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sort options (gated by showSortOptions)', () => {
|
||||
it('hides newest/oldest sort buttons when showSortOptions is false', () => {
|
||||
mountWithModels({ showSortOptions: false })
|
||||
expect(screen.queryByRole('button', { name: KEYS.newest })).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: KEYS.oldest })).toBeNull()
|
||||
})
|
||||
|
||||
it('shows newest and oldest options when showSortOptions is true', () => {
|
||||
mountWithModels({ showSortOptions: true })
|
||||
expect(getButton(KEYS.newest)).toBeTruthy()
|
||||
expect(getButton(KEYS.oldest)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('hides longest/fastest options unless showGenerationTimeSort is also true', () => {
|
||||
mountWithModels({
|
||||
showSortOptions: true,
|
||||
showGenerationTimeSort: false
|
||||
})
|
||||
expect(screen.queryByRole('button', { name: KEYS.longest })).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: KEYS.fastest })).toBeNull()
|
||||
})
|
||||
|
||||
it('shows generation-time options when both flags are true', () => {
|
||||
mountWithModels({
|
||||
showSortOptions: true,
|
||||
showGenerationTimeSort: true
|
||||
})
|
||||
expect(getButton(KEYS.longest)).toBeTruthy()
|
||||
expect(getButton(KEYS.fastest)).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('v-model:sortBy round-trip', () => {
|
||||
const cases: Array<{ key: keyof typeof KEYS; expected: SortBy }> = [
|
||||
{ key: 'newest', expected: 'newest' },
|
||||
{ key: 'oldest', expected: 'oldest' },
|
||||
{ key: 'longest', expected: 'longest' },
|
||||
{ key: 'fastest', expected: 'fastest' }
|
||||
]
|
||||
|
||||
for (const { key, expected } of cases) {
|
||||
it(`emits ${expected} when ${key} is clicked`, async () => {
|
||||
const { sortBy, user } = mountWithModels({
|
||||
sortBy: 'newest',
|
||||
showSortOptions: true,
|
||||
showGenerationTimeSort: true
|
||||
})
|
||||
await user.click(getButton(KEYS[key]))
|
||||
expect(sortBy.value).toBe(expected)
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,191 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useMediaAssetFiltering } from '@/platform/assets/composables/useMediaAssetFiltering'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
|
||||
interface AssetSpec {
|
||||
id: string
|
||||
name: string
|
||||
/** Unix ms; written into both `created_at` (ISO) and `user_metadata.create_time`. */
|
||||
createTime?: number
|
||||
/** Seconds, written into `user_metadata.executionTimeInSeconds`. */
|
||||
executionSeconds?: number
|
||||
}
|
||||
|
||||
function makeAsset(spec: AssetSpec): AssetItem {
|
||||
const userMetadata: Record<string, unknown> = {}
|
||||
if (spec.createTime !== undefined) {
|
||||
userMetadata.create_time = spec.createTime
|
||||
}
|
||||
if (spec.executionSeconds !== undefined) {
|
||||
userMetadata.executionTimeInSeconds = spec.executionSeconds
|
||||
}
|
||||
return {
|
||||
id: spec.id,
|
||||
name: spec.name,
|
||||
tags: [],
|
||||
created_at:
|
||||
spec.createTime !== undefined
|
||||
? new Date(spec.createTime).toISOString()
|
||||
: undefined,
|
||||
user_metadata: userMetadata
|
||||
}
|
||||
}
|
||||
|
||||
function ids(assets: AssetItem[]): string[] {
|
||||
return assets.map((a) => a.id)
|
||||
}
|
||||
|
||||
describe('useMediaAssetFiltering', () => {
|
||||
describe('media-type filter', () => {
|
||||
it('returns all assets when no filters are selected', () => {
|
||||
const assets = ref<AssetItem[]>([
|
||||
makeAsset({ id: 'a', name: 'a.png' }),
|
||||
makeAsset({ id: 'b', name: 'b.mp4' }),
|
||||
makeAsset({ id: 'c', name: 'c.glb' })
|
||||
])
|
||||
const { filteredAssets } = useMediaAssetFiltering(assets)
|
||||
|
||||
expect(ids(filteredAssets.value).sort()).toEqual(['a', 'b', 'c'])
|
||||
})
|
||||
|
||||
it('filters to a single media kind', () => {
|
||||
const assets = ref<AssetItem[]>([
|
||||
makeAsset({ id: 'img', name: 'img.png' }),
|
||||
makeAsset({ id: 'vid', name: 'vid.mp4' }),
|
||||
makeAsset({ id: 'aud', name: 'aud.wav' }),
|
||||
makeAsset({ id: '3d', name: 'model.glb' })
|
||||
])
|
||||
const { mediaTypeFilters, filteredAssets } =
|
||||
useMediaAssetFiltering(assets)
|
||||
|
||||
mediaTypeFilters.value = ['video']
|
||||
expect(ids(filteredAssets.value)).toEqual(['vid'])
|
||||
})
|
||||
|
||||
it('combines multiple kinds via OR', () => {
|
||||
const assets = ref<AssetItem[]>([
|
||||
makeAsset({ id: 'img', name: 'img.png' }),
|
||||
makeAsset({ id: 'vid', name: 'vid.mp4' }),
|
||||
makeAsset({ id: 'aud', name: 'aud.wav' })
|
||||
])
|
||||
const { mediaTypeFilters, filteredAssets } =
|
||||
useMediaAssetFiltering(assets)
|
||||
|
||||
mediaTypeFilters.value = ['image', 'audio']
|
||||
expect(ids(filteredAssets.value).sort()).toEqual(['aud', 'img'])
|
||||
})
|
||||
|
||||
it("normalizes '3D' filename detection to lowercase '3d' for filter match", () => {
|
||||
// getMediaTypeFromFilename returns '3D' for .glb, but the filter array
|
||||
// stores the lowercase '3d' the menu emits — composable must reconcile.
|
||||
const assets = ref<AssetItem[]>([
|
||||
makeAsset({ id: 'img', name: 'img.png' }),
|
||||
makeAsset({ id: 'mesh', name: 'mesh.glb' })
|
||||
])
|
||||
const { mediaTypeFilters, filteredAssets } =
|
||||
useMediaAssetFiltering(assets)
|
||||
|
||||
mediaTypeFilters.value = ['3d']
|
||||
expect(ids(filteredAssets.value)).toEqual(['mesh'])
|
||||
})
|
||||
|
||||
it('excludes unsupported media kinds (e.g. text) when any filter is active', () => {
|
||||
const assets = ref<AssetItem[]>([
|
||||
makeAsset({ id: 'img', name: 'img.png' }),
|
||||
makeAsset({ id: 'doc', name: 'notes.txt' })
|
||||
])
|
||||
const { mediaTypeFilters, filteredAssets } =
|
||||
useMediaAssetFiltering(assets)
|
||||
|
||||
mediaTypeFilters.value = ['image']
|
||||
expect(ids(filteredAssets.value)).toEqual(['img'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('sort', () => {
|
||||
const t1 = 1_000_000
|
||||
const t2 = 2_000_000
|
||||
const t3 = 3_000_000
|
||||
|
||||
it('defaults to newest first by create_time descending', () => {
|
||||
const assets = ref<AssetItem[]>([
|
||||
makeAsset({ id: 'old', name: 'a.png', createTime: t1 }),
|
||||
makeAsset({ id: 'mid', name: 'b.png', createTime: t2 }),
|
||||
makeAsset({ id: 'new', name: 'c.png', createTime: t3 })
|
||||
])
|
||||
const { filteredAssets } = useMediaAssetFiltering(assets)
|
||||
|
||||
expect(ids(filteredAssets.value)).toEqual(['new', 'mid', 'old'])
|
||||
})
|
||||
|
||||
it('sorts oldest first by create_time ascending', () => {
|
||||
const assets = ref<AssetItem[]>([
|
||||
makeAsset({ id: 'new', name: 'c.png', createTime: t3 }),
|
||||
makeAsset({ id: 'old', name: 'a.png', createTime: t1 }),
|
||||
makeAsset({ id: 'mid', name: 'b.png', createTime: t2 })
|
||||
])
|
||||
const { sortBy, filteredAssets } = useMediaAssetFiltering(assets)
|
||||
|
||||
sortBy.value = 'oldest'
|
||||
expect(ids(filteredAssets.value)).toEqual(['old', 'mid', 'new'])
|
||||
})
|
||||
|
||||
it('sorts longest by executionTimeInSeconds descending', () => {
|
||||
const assets = ref<AssetItem[]>([
|
||||
makeAsset({ id: 'fast', name: 'a.png', executionSeconds: 3 }),
|
||||
makeAsset({ id: 'slow', name: 'b.png', executionSeconds: 10 }),
|
||||
makeAsset({ id: 'mid', name: 'c.png', executionSeconds: 5 })
|
||||
])
|
||||
const { sortBy, filteredAssets } = useMediaAssetFiltering(assets)
|
||||
|
||||
sortBy.value = 'longest'
|
||||
expect(ids(filteredAssets.value)).toEqual(['slow', 'mid', 'fast'])
|
||||
})
|
||||
|
||||
it('sorts fastest by executionTimeInSeconds ascending', () => {
|
||||
const assets = ref<AssetItem[]>([
|
||||
makeAsset({ id: 'fast', name: 'a.png', executionSeconds: 3 }),
|
||||
makeAsset({ id: 'slow', name: 'b.png', executionSeconds: 10 }),
|
||||
makeAsset({ id: 'mid', name: 'c.png', executionSeconds: 5 })
|
||||
])
|
||||
const { sortBy, filteredAssets } = useMediaAssetFiltering(assets)
|
||||
|
||||
sortBy.value = 'fastest'
|
||||
expect(ids(filteredAssets.value)).toEqual(['fast', 'mid', 'slow'])
|
||||
})
|
||||
|
||||
it('falls back to created_at when user_metadata.create_time is absent', () => {
|
||||
const a = makeAsset({ id: 'a', name: 'a.png', createTime: t1 })
|
||||
const b = makeAsset({ id: 'b', name: 'b.png', createTime: t2 })
|
||||
// Strip the user_metadata.create_time path on both, leaving created_at.
|
||||
a.user_metadata = {}
|
||||
b.user_metadata = {}
|
||||
const assets = ref<AssetItem[]>([a, b])
|
||||
const { filteredAssets } = useMediaAssetFiltering(assets)
|
||||
|
||||
expect(ids(filteredAssets.value)).toEqual(['b', 'a'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('composition', () => {
|
||||
it('applies media-type filter then sort', () => {
|
||||
const t1 = 1_000_000
|
||||
const t2 = 2_000_000
|
||||
const t3 = 3_000_000
|
||||
const assets = ref<AssetItem[]>([
|
||||
makeAsset({ id: 'img-old', name: 'a.png', createTime: t1 }),
|
||||
makeAsset({ id: 'vid', name: 'b.mp4', createTime: t2 }),
|
||||
makeAsset({ id: 'img-new', name: 'c.png', createTime: t3 })
|
||||
])
|
||||
const { mediaTypeFilters, sortBy, filteredAssets } =
|
||||
useMediaAssetFiltering(assets)
|
||||
|
||||
mediaTypeFilters.value = ['image']
|
||||
sortBy.value = 'oldest'
|
||||
|
||||
expect(ids(filteredAssets.value)).toEqual(['img-old', 'img-new'])
|
||||
})
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user