mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-21 07:16:29 +00:00
Compare commits
38 Commits
fix/lgraph
...
test/cover
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75860fc322 | ||
|
|
c594e30b84 | ||
|
|
2ade779a81 | ||
|
|
25f493bd30 | ||
|
|
b8b5e1ec1f | ||
|
|
59ef69f355 | ||
|
|
9ad052467d | ||
|
|
aa730c8cb5 | ||
|
|
cc1fe65348 | ||
|
|
0f66f76b87 | ||
|
|
bc16865019 | ||
|
|
206a367379 | ||
|
|
7e8ede376b | ||
|
|
7bfbd0d7f3 | ||
|
|
32b266f3e9 | ||
|
|
2d4ca9c387 | ||
|
|
177224452e | ||
|
|
6bf75b4cf0 | ||
|
|
7a13340989 | ||
|
|
1b07e82ff7 | ||
|
|
9f4c54eb24 | ||
|
|
6f6fc88b0f | ||
|
|
492bec28c8 | ||
|
|
13b660a15b | ||
|
|
b232831441 | ||
|
|
996e362ba6 | ||
|
|
b0fa179b87 | ||
|
|
ba6dd2a09c | ||
|
|
4a9001f675 | ||
|
|
9cf035879f | ||
|
|
d0e9984a73 | ||
|
|
125c11b3d0 | ||
|
|
725ed120e8 | ||
|
|
453a0edd1e | ||
|
|
5a8ded7959 | ||
|
|
bb23b9352c | ||
|
|
25f0b41f63 | ||
|
|
e7673fcca7 |
@@ -46,3 +46,9 @@ 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,6 +2,7 @@ dist/
|
||||
.astro/
|
||||
test-results/
|
||||
playwright-report/
|
||||
results.json
|
||||
|
||||
# Platform-specific Playwright snapshots (CI runs Linux)
|
||||
*-win32.png
|
||||
|
||||
123
apps/website/README.md
Normal file
123
apps/website/README.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# @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,6 +7,12 @@ 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'
|
||||
},
|
||||
|
||||
57
apps/website/e2e/careers.spec.ts
Normal file
57
apps/website/e2e/careers.spec.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
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,10 +9,13 @@
|
||||
"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"
|
||||
"test:visual:update": "playwright test --project visual --update-snapshots",
|
||||
"ashby:refresh-snapshot": "tsx ./scripts/refresh-ashby-snapshot.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/sitemap": "catalog:",
|
||||
@@ -23,7 +26,8 @@
|
||||
"cva": "catalog:",
|
||||
"gsap": "catalog:",
|
||||
"lenis": "catalog:",
|
||||
"vue": "catalog:"
|
||||
"vue": "catalog:",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/check": "catalog:",
|
||||
@@ -32,7 +36,9 @@
|
||||
"@tailwindcss/vite": "catalog:",
|
||||
"astro": "catalog:",
|
||||
"tailwindcss": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
"tsx": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
},
|
||||
"nx": {
|
||||
"tags": [
|
||||
@@ -89,6 +95,22 @@
|
||||
"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": [
|
||||
|
||||
33
apps/website/scripts/refresh-ashby-snapshot.ts
Normal file
33
apps/website/scripts/refresh-ashby-snapshot.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
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`
|
||||
)
|
||||
104
apps/website/src/components/about/StorySection.vue
Normal file
104
apps/website/src/components/about/StorySection.vue
Normal file
@@ -0,0 +1,104 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const investors = [
|
||||
{ name: 'CRAFT', icon: '/icons/investors/craft.svg' },
|
||||
{ name: 'PACE CAPITAL', icon: '/icons/investors/pace-capital.svg' },
|
||||
{ name: 'chemistry_', icon: '/icons/investors/chemistry.svg' },
|
||||
{ name: 'ABSTRACT', icon: '/icons/investors/abstract.svg' },
|
||||
{ name: 'TRUARROW PARTNERS', icon: '/icons/investors/truarrow-partners.svg' },
|
||||
{ name: 'ESSENCE', icon: '/icons/investors/essence.svg' }
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="px-6 py-24 lg:px-20 lg:py-32">
|
||||
<div class="mx-auto text-center">
|
||||
<span
|
||||
class="text-primary-comfy-yellow text-xs font-semibold tracking-widest uppercase"
|
||||
>
|
||||
{{ t('about.story.label', locale) }}
|
||||
</span>
|
||||
<h2
|
||||
class="text-primary-comfy-canvas mt-6 text-3xl font-light lg:text-5xl"
|
||||
>
|
||||
{{ t('about.story.headingBefore', locale)
|
||||
}}<span class="text-primary-comfy-yellow">{{
|
||||
t('about.story.headingHighlight', locale)
|
||||
}}</span
|
||||
>{{ t('about.story.headingAfter', locale) }}
|
||||
</h2>
|
||||
<p class="text-primary-warm-white mt-8 text-base/relaxed lg:text-lg">
|
||||
{{ t('about.story.body', locale) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Investor card -->
|
||||
<div
|
||||
class="mx-auto mt-16 max-w-5xl rounded-4xl border border-white/10 bg-black/30 p-8 lg:p-12"
|
||||
>
|
||||
<div class="inline-flex items-center">
|
||||
<!-- OUR badge (shorter) -->
|
||||
<div class="relative z-10 flex h-9 items-center">
|
||||
<img src="/icons/node-left.svg" alt="" class="h-full w-auto" />
|
||||
<span
|
||||
class="bg-primary-comfy-yellow text-primary-comfy-ink flex h-full items-center px-2 text-sm font-bold tracking-wider"
|
||||
>
|
||||
OUR
|
||||
</span>
|
||||
</div>
|
||||
<!-- Union connector (overlaps both badges to eliminate seams) -->
|
||||
<img
|
||||
src="/icons/node-union-2size-reverse.svg"
|
||||
alt=""
|
||||
class="relative z-20 -mx-px h-12 w-auto"
|
||||
/>
|
||||
<!-- INVESTORS badge (taller) -->
|
||||
<div class="relative z-10 flex h-12 items-center">
|
||||
<span
|
||||
class="bg-primary-comfy-yellow text-primary-comfy-ink flex h-full items-center px-3 text-lg font-bold tracking-wider"
|
||||
>
|
||||
INVESTORS
|
||||
</span>
|
||||
<img src="/icons/node-right.svg" alt="" class="h-full w-auto" />
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
class="text-primary-warm-white mt-6 max-w-3xl text-sm/relaxed lg:text-base"
|
||||
>
|
||||
{{ t('about.story.investorsBody', locale) }}
|
||||
</p>
|
||||
<div class="mt-10 grid grid-cols-2 gap-4 sm:grid-cols-3 lg:gap-6">
|
||||
<div
|
||||
v-for="investor in investors"
|
||||
:key="investor.name"
|
||||
class="flex h-16 items-center justify-center rounded-xl border border-white/10 bg-white/5 px-4"
|
||||
>
|
||||
<img
|
||||
:src="investor.icon"
|
||||
:alt="investor.name"
|
||||
class="max-h-8 w-auto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quote card -->
|
||||
<div
|
||||
class="bg-primary-comfy-yellow mx-auto mt-12 max-w-5xl rounded-4xl p-10 lg:p-16"
|
||||
>
|
||||
<p class="text-primary-comfy-ink text-xl/relaxed font-medium lg:text-3xl">
|
||||
{{ t('about.quote.text', locale) }}
|
||||
</p>
|
||||
<p
|
||||
class="text-primary-comfy-ink/70 mt-8 text-sm font-semibold lg:text-base"
|
||||
>
|
||||
{{ t('about.quote.attribution', locale) }}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,121 +1,42 @@
|
||||
<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' } = defineProps<{ locale?: Locale }>()
|
||||
const { locale = 'en', departments = [] } = defineProps<{
|
||||
locale?: Locale
|
||||
departments?: readonly Department[]
|
||||
}>()
|
||||
|
||||
const activeCategory = ref('all')
|
||||
|
||||
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 visibleDepartments = computed(() =>
|
||||
departments.filter((d) => d.roles.length > 0)
|
||||
)
|
||||
|
||||
const categories = computed(() => [
|
||||
{ label: 'ALL', value: 'all' },
|
||||
...departments.map((d) => ({ label: d.name, value: d.key }))
|
||||
...visibleDepartments.value.map((d) => ({ label: d.name, value: d.key }))
|
||||
])
|
||||
|
||||
const filteredDepartments = computed(() =>
|
||||
activeCategory.value === 'all'
|
||||
? departments
|
||||
: departments.filter((d) => d.key === activeCategory.value)
|
||||
? visibleDepartments.value
|
||||
: visibleDepartments.value.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">
|
||||
<section class="px-6 py-20 md:px-20 md:py-32" data-testid="careers-roles">
|
||||
<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"
|
||||
@@ -126,6 +47,7 @@ const filteredDepartments = computed(() =>
|
||||
{{ t('careers.roles.heading', locale) }}
|
||||
</h2>
|
||||
<CategoryNav
|
||||
v-if="hasRoles"
|
||||
v-model="activeCategory"
|
||||
:categories="categories"
|
||||
class="mt-4"
|
||||
@@ -133,8 +55,15 @@ const filteredDepartments = computed(() =>
|
||||
</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"
|
||||
@@ -147,10 +76,11 @@ const filteredDepartments = computed(() =>
|
||||
<a
|
||||
v-for="role in dept.roles"
|
||||
:key="role.id"
|
||||
:href="`https://jobs.ashbyhq.com/comfy-org/${role.id}`"
|
||||
:href="role.applyUrl"
|
||||
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
|
||||
|
||||
@@ -223,7 +223,7 @@ onUnmounted(() => {
|
||||
|
||||
<div class="mt-8 flex flex-col gap-4 lg:flex-row">
|
||||
<BrandButton
|
||||
:href="externalLinks.platform"
|
||||
:href="externalLinks.apiKeys"
|
||||
size="lg"
|
||||
class="text-center lg:min-w-60 lg:p-4"
|
||||
>
|
||||
|
||||
@@ -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/api/logo-purple.webp'
|
||||
image: 'https://media.comfy.org/website/enterprise/enterprise_node_1.webp'
|
||||
},
|
||||
{
|
||||
number: '02',
|
||||
titleKey: 'api.steps.step2.title' as const,
|
||||
descriptionKey: 'api.steps.step2.description' as const,
|
||||
image: 'https://media.comfy.org/website/api/logo-yellow.webp'
|
||||
image: 'https://media.comfy.org/website/enterprise/enterprise_node_2.webp'
|
||||
},
|
||||
{
|
||||
number: '03',
|
||||
@@ -61,7 +61,7 @@ const steps = [
|
||||
class="mt-12 flex flex-col items-center gap-4 lg:flex-row lg:justify-center"
|
||||
>
|
||||
<BrandButton
|
||||
:href="externalLinks.cloud"
|
||||
:href="externalLinks.apiKeys"
|
||||
variant="solid"
|
||||
size="lg"
|
||||
class="w-full text-center lg:w-auto lg:min-w-48"
|
||||
@@ -69,7 +69,7 @@ const steps = [
|
||||
{{ t('api.hero.getApiKeys', locale) }}
|
||||
</BrandButton>
|
||||
<BrandButton
|
||||
:href="externalLinks.docs"
|
||||
:href="externalLinks.docsApi"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
class="w-full text-center lg:w-auto lg:min-w-48"
|
||||
|
||||
@@ -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/api/logo-purple.webp'
|
||||
image: 'https://media.comfy.org/website/enterprise/enterprise_node_1.webp'
|
||||
},
|
||||
{
|
||||
titleKey: 'enterprise.byoKey.card2.title' as const,
|
||||
descriptionKey: 'enterprise.byoKey.card2.description' as const,
|
||||
image: 'https://media.comfy.org/website/api/logo-yellow.webp'
|
||||
image: 'https://media.comfy.org/website/enterprise/enterprise_node_2.webp'
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
@@ -16,9 +16,11 @@ const midRightRef = ref<HTMLElement>()
|
||||
const bottomLeftRef = ref<HTMLElement>()
|
||||
const bottomRightRef = ref<HTMLElement>()
|
||||
|
||||
useParallax([topLeftRef, topRightRef], { trigger: sectionRef, y: 200 })
|
||||
useParallax([midLeftRef, midRightRef], { trigger: sectionRef, y: 300 })
|
||||
useParallax([bottomLeftRef, bottomRightRef], { trigger: sectionRef, y: 400 })
|
||||
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 })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -44,162 +44,303 @@ onMounted(() => {
|
||||
<svg
|
||||
ref="svgRef"
|
||||
class="block size-full"
|
||||
viewBox="600 -50 1000 1100"
|
||||
viewBox="0 0 1600 1046"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<!-- Ripple rings -->
|
||||
<path
|
||||
class="ripple-path"
|
||||
d="M862.278 684.584L1064.22 801.244C1091.06 816.752 1134.58 816.764 1161.41 801.27L1363.29 684.716C1380.29 674.902 1395.18 648.204 1395.17 628.577L1395.11 395.363C1395.1 364.36 1373.34 326.656 1346.49 311.148L1144.55 194.488C1127.56 184.67 1097 184.223 1080 194.037L878.12 310.591C851.283 326.085 829.535 363.778 829.543 394.78L829.604 627.993C829.61 647.659 845.248 674.747 862.278 684.584Z"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="ripple-path ripple-delay-1"
|
||||
d="M862.278 684.584L1064.22 801.244C1091.06 816.752 1134.58 816.764 1161.41 801.27L1363.29 684.716C1380.29 674.902 1395.18 648.204 1395.17 628.577L1395.11 395.363C1395.1 364.36 1373.34 326.656 1346.49 311.148L1144.55 194.488C1127.56 184.67 1097 184.223 1080 194.037L878.12 310.591C851.283 326.085 829.535 363.778 829.543 394.78L829.604 627.993C829.61 647.659 845.248 674.747 862.278 684.584Z"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="ripple-path ripple-delay-2"
|
||||
d="M862.278 684.584L1064.22 801.244C1091.06 816.752 1134.58 816.764 1161.41 801.27L1363.29 684.716C1380.29 674.902 1395.18 648.204 1395.17 628.577L1395.11 395.363C1395.1 364.36 1373.34 326.656 1346.49 311.148L1144.55 194.488C1127.56 184.67 1097 184.223 1080 194.037L878.12 310.591C851.283 326.085 829.535 363.778 829.543 394.78L829.604 627.993C829.61 647.659 845.248 674.747 862.278 684.584Z"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="ripple-path ripple-delay-3"
|
||||
d="M862.278 684.584L1064.22 801.244C1091.06 816.752 1134.58 816.764 1161.41 801.27L1363.29 684.716C1380.29 674.902 1395.18 648.204 1395.17 628.577L1395.11 395.363C1395.1 364.36 1373.34 326.656 1346.49 311.148L1144.55 194.488C1127.56 184.67 1097 184.223 1080 194.037L878.12 310.591C851.283 326.085 829.535 363.778 829.543 394.78L829.604 627.993C829.61 647.659 845.248 674.747 862.278 684.584Z"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<g clip-path="url(#enterpriseHeroClip)">
|
||||
<rect width="1600" height="1046" fill="#211927" />
|
||||
<rect
|
||||
width="800"
|
||||
height="800"
|
||||
transform="translate(712 112)"
|
||||
fill="#211927"
|
||||
/>
|
||||
|
||||
<!-- Exploding block cluster -->
|
||||
<g stroke="#4D3762" stroke-width="2">
|
||||
<!-- Ripple rings -->
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1018.44 635.715L1018.45 581.73C1018.46 574.554 1013.42 565.829 1007.21 562.242L960.479 535.262C956.544 532.99 949.469 533.096 945.535 535.368L898.79 562.373C892.576 565.963 887.537 574.691 887.535 581.867L887.52 635.852C887.519 640.395 890.967 646.574 894.902 648.845L941.632 675.825C947.845 679.412 957.918 679.409 964.132 675.819L1010.88 648.815C1014.82 646.538 1018.44 640.267 1018.44 635.715Z"
|
||||
fill="#37303F"
|
||||
class="ripple-path"
|
||||
d="M862.278 684.584L1064.22 801.244C1091.06 816.752 1134.58 816.764 1161.41 801.27L1363.29 684.716C1380.29 674.902 1395.18 648.204 1395.17 628.577L1395.11 395.363C1395.1 364.36 1373.34 326.656 1346.49 311.148L1144.55 194.488C1127.56 184.67 1097 184.223 1080 194.037L878.12 310.591C851.283 326.085 829.535 363.778 829.543 394.78L829.604 627.993C829.61 647.659 845.248 674.747 862.278 684.584Z"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1098.58 681.434L1098.6 627.449C1098.6 620.273 1093.57 611.548 1087.35 607.961L1040.62 580.981C1036.69 578.709 1029.61 578.814 1025.68 581.087L978.934 608.092C972.72 611.682 967.681 620.409 967.679 627.586L967.665 681.57C967.664 686.114 971.111 692.292 975.046 694.564L1021.78 721.544C1027.99 725.131 1038.06 725.128 1044.28 721.538L1091.02 694.534C1094.96 692.256 1098.58 685.986 1098.58 681.434Z"
|
||||
fill="#251D2B"
|
||||
class="ripple-path delay-1"
|
||||
d="M862.278 684.584L1064.22 801.244C1091.06 816.752 1134.58 816.764 1161.41 801.27L1363.29 684.716C1380.29 674.902 1395.18 648.204 1395.17 628.577L1395.11 395.363C1395.1 364.36 1373.34 326.656 1346.49 311.148L1144.55 194.488C1127.56 184.67 1097 184.223 1080 194.037L878.12 310.591C851.283 326.085 829.535 363.778 829.543 394.78L829.604 627.993C829.61 647.659 845.248 674.747 862.278 684.584Z"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1205.98 635.714L1205.97 581.73C1205.97 574.553 1211 565.828 1217.21 562.241L1263.94 535.261C1267.88 532.989 1274.95 533.095 1278.89 535.367L1325.63 562.372C1331.85 565.962 1336.89 574.69 1336.89 581.866L1336.9 635.851C1336.9 640.394 1333.46 646.573 1329.52 648.844L1282.79 675.824C1276.58 679.411 1266.5 679.408 1260.29 675.818L1213.54 648.814C1209.6 646.537 1205.98 640.266 1205.98 635.714Z"
|
||||
fill="#37303F"
|
||||
class="ripple-path delay-2"
|
||||
d="M862.278 684.584L1064.22 801.244C1091.06 816.752 1134.58 816.764 1161.41 801.27L1363.29 684.716C1380.29 674.902 1395.18 648.204 1395.17 628.577L1395.11 395.363C1395.1 364.36 1373.34 326.656 1346.49 311.148L1144.55 194.488C1127.56 184.67 1097 184.223 1080 194.037L878.12 310.591C851.283 326.085 829.535 363.778 829.543 394.78L829.604 627.993C829.61 647.659 845.248 674.747 862.278 684.584Z"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1125.83 681.434L1125.81 627.45C1125.81 620.273 1130.84 611.548 1137.06 607.961L1183.79 580.981C1187.72 578.71 1194.8 578.815 1198.73 581.087L1245.48 608.092C1251.69 611.682 1256.73 620.41 1256.73 627.586L1256.75 681.571C1256.75 686.114 1253.3 692.293 1249.36 694.565L1202.63 721.545C1196.42 725.131 1186.35 725.128 1180.13 721.539L1133.39 694.534C1129.45 692.257 1125.83 685.987 1125.83 681.434Z"
|
||||
fill="#251D2B"
|
||||
class="ripple-path delay-3"
|
||||
d="M862.278 684.584L1064.22 801.244C1091.06 816.752 1134.58 816.764 1161.41 801.27L1363.29 684.716C1380.29 674.902 1395.18 648.204 1395.17 628.577L1395.11 395.363C1395.1 364.36 1373.34 326.656 1346.49 311.148L1144.55 194.488C1127.56 184.67 1097 184.223 1080 194.037L878.12 310.591C851.283 326.085 829.535 363.778 829.543 394.78L829.604 627.993C829.61 647.659 845.248 674.747 862.278 684.584Z"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1045.67 726.53L1045.66 672.545C1045.65 665.369 1050.69 656.644 1056.9 653.057L1103.63 626.077C1107.57 623.805 1114.64 623.911 1118.57 626.183L1165.32 653.188C1171.53 656.778 1176.57 665.506 1176.57 672.682L1176.59 726.667C1176.59 731.21 1173.14 737.388 1169.21 739.66L1122.48 766.64C1116.26 770.227 1106.19 770.224 1099.98 766.634L1053.23 739.63C1049.29 737.353 1045.67 731.082 1045.67 726.53Z"
|
||||
fill="#37303F"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1175.17 536.369L1175.18 482.384C1175.19 475.208 1170.15 466.483 1163.94 462.896L1117.21 435.916C1113.27 433.644 1106.2 433.749 1102.27 436.022L1055.52 463.027C1049.31 466.617 1044.27 475.344 1044.27 482.521L1044.25 536.506C1044.25 541.049 1047.7 547.227 1051.63 549.499L1098.36 576.479C1104.58 580.066 1114.65 580.063 1120.86 576.473L1167.61 549.469C1171.55 547.191 1175.17 540.921 1175.17 536.369Z"
|
||||
fill="#251D2B"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1052.83 458.666L1099.57 485.671C1105.79 489.261 1115.86 489.263 1122.07 485.677L1168.8 458.697C1172.74 456.425 1176.19 450.245 1176.18 445.702L1176.17 391.717C1176.17 384.54 1171.13 375.812 1164.92 372.223L1118.17 345.218C1114.24 342.945 1107.16 342.842 1103.23 345.114L1056.5 372.094C1050.28 375.68 1045.25 384.405 1045.25 391.582L1045.27 445.566C1045.27 450.119 1048.89 456.389 1052.83 458.666Z"
|
||||
fill="#251D2B"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1247.76 420.64L1201.02 393.635C1194.81 390.045 1184.73 390.043 1178.52 393.629L1131.79 420.609C1127.85 422.881 1124.41 429.061 1124.41 433.604L1124.42 487.589C1124.43 494.766 1129.46 503.493 1135.68 507.083L1182.42 534.088C1186.36 536.361 1193.43 536.464 1197.37 534.192L1244.1 507.212C1250.31 503.626 1255.34 494.901 1255.34 487.724L1255.33 433.74C1255.33 429.187 1251.71 422.917 1247.76 420.64Z"
|
||||
fill="#251D2B"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M975.833 420.641L1022.58 393.636C1028.79 390.047 1038.87 390.044 1045.08 393.63L1091.81 420.61C1095.74 422.882 1099.19 429.062 1099.19 433.606L1099.17 487.59C1099.17 494.767 1094.13 503.495 1087.92 507.085L1041.17 534.089C1037.24 536.362 1030.17 536.465 1026.23 534.194L979.501 507.214C973.288 503.627 968.254 494.902 968.256 487.725L968.27 433.741C968.271 429.188 971.891 422.918 975.833 420.641Z"
|
||||
fill="#251D2B"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1018.03 536.368L1018.04 482.384C1018.04 475.207 1013.01 466.482 1006.8 462.895L960.065 435.915C956.13 433.644 949.055 433.749 945.121 436.021L898.376 463.026C892.162 466.616 887.123 475.344 887.121 482.52L887.106 536.505C887.105 541.048 890.553 547.227 894.488 549.499L941.218 576.479C947.431 580.065 957.504 580.062 963.718 576.473L1010.46 549.468C1014.4 547.191 1018.02 540.921 1018.03 536.368Z"
|
||||
fill="#251D2B"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1098.18 582.085L1098.19 528.1C1098.19 520.924 1093.16 512.199 1086.95 508.612L1040.22 481.632C1036.28 479.36 1029.21 479.465 1025.27 481.738L978.528 508.743C972.314 512.333 967.275 521.061 967.273 528.237L967.259 582.222C967.257 586.765 970.705 592.943 974.64 595.215L1021.37 622.195C1027.58 625.782 1037.66 625.779 1043.87 622.189L1090.62 595.185C1094.56 592.907 1098.18 586.637 1098.18 582.085Z"
|
||||
fill="#F2FF59"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1125.42 582.085L1125.4 528.101C1125.4 520.924 1130.43 512.199 1136.65 508.613L1183.38 481.633C1187.31 479.361 1194.39 479.466 1198.32 481.739L1245.07 508.743C1251.28 512.333 1256.32 521.061 1256.32 528.238L1256.34 582.222C1256.34 586.766 1252.89 592.944 1248.95 595.216L1202.22 622.196C1196.01 625.782 1185.94 625.78 1179.72 622.19L1132.98 595.185C1129.04 592.908 1125.42 586.638 1125.42 582.085Z"
|
||||
fill="#F2FF59"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1205.57 536.367L1205.56 482.383C1205.56 475.206 1210.59 466.481 1216.8 462.894L1263.53 435.914C1267.47 433.643 1274.54 433.748 1278.48 436.02L1325.22 463.025C1331.44 466.615 1336.48 475.343 1336.48 482.519L1336.49 536.504C1336.49 541.047 1333.04 547.226 1329.11 549.497L1282.38 576.477C1276.17 580.064 1266.09 580.061 1259.88 576.471L1213.13 549.467C1209.19 547.19 1205.57 540.919 1205.57 536.367Z"
|
||||
fill="#251D2B"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1045.26 627.181L1045.25 573.197C1045.24 566.02 1050.28 557.295 1056.49 553.709L1103.22 526.729C1107.16 524.457 1114.23 524.562 1118.16 526.835L1164.91 553.839C1171.12 557.429 1176.16 566.157 1176.16 573.333L1176.18 627.318C1176.18 631.862 1172.73 638.04 1168.8 640.312L1122.07 667.292C1115.85 670.878 1105.78 670.876 1099.57 667.286L1052.82 640.281C1048.88 638.004 1045.26 631.734 1045.26 627.181Z"
|
||||
fill="#251D2B"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1175.17 445.81L1175.18 391.826C1175.18 384.649 1170.15 375.924 1163.94 372.337L1117.21 345.357C1113.27 343.086 1106.2 343.191 1102.26 345.464L1055.52 372.468C1049.3 376.058 1044.26 384.786 1044.26 391.962L1044.25 445.947C1044.25 450.49 1047.69 456.669 1051.63 458.941L1098.36 485.921C1104.57 489.507 1114.64 489.505 1120.86 485.915L1167.6 458.91C1171.55 456.633 1175.17 450.363 1175.17 445.81Z"
|
||||
fill="#F2FF59"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1098.17 491.528L1098.18 437.544C1098.19 430.367 1093.15 421.642 1086.94 418.056L1040.21 391.076C1036.27 388.804 1029.2 388.909 1025.27 391.182L978.52 418.186C972.306 421.776 967.267 430.504 967.265 437.681L967.251 491.665C967.25 496.209 970.697 502.387 974.632 504.659L1021.36 531.639C1027.58 535.225 1037.65 535.223 1043.86 531.633L1090.61 504.628C1094.55 502.351 1098.17 496.081 1098.17 491.528Z"
|
||||
fill="#251D2B"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1247.76 330.081L1201.02 303.077C1194.81 299.487 1184.73 299.484 1178.52 303.071L1131.79 330.051C1127.85 332.322 1124.41 338.502 1124.41 343.046L1124.42 397.031C1124.43 404.207 1129.46 412.935 1135.67 416.525L1182.42 443.529C1186.35 445.802 1193.43 445.906 1197.36 443.634L1244.09 416.654C1250.31 413.067 1255.34 404.342 1255.34 397.166L1255.32 343.181C1255.32 338.629 1251.7 332.358 1247.76 330.081Z"
|
||||
fill="#251D2B"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M975.826 330.082L1022.57 303.078C1028.78 299.488 1038.86 299.485 1045.07 303.072L1091.8 330.052C1095.74 332.323 1099.18 338.503 1099.18 343.047L1099.17 397.032C1099.16 404.208 1094.13 412.936 1087.91 416.526L1041.17 443.53C1037.23 445.803 1030.16 445.907 1026.22 443.635L979.493 416.655C973.281 413.068 968.246 404.343 968.248 397.167L968.262 343.182C968.263 338.63 971.884 332.359 975.826 330.082Z"
|
||||
fill="#251D2B"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1018.02 445.809L1018.04 391.825C1018.04 384.649 1013 375.923 1006.79 372.337L960.061 345.357C956.126 343.085 949.051 343.19 945.117 345.463L898.372 372.468C892.158 376.057 887.119 384.785 887.117 391.962L887.103 445.946C887.101 450.49 890.549 456.668 894.484 458.94L941.215 485.92C947.427 489.507 957.5 489.504 963.714 485.914L1010.46 458.909C1014.4 456.632 1018.02 450.362 1018.02 445.809Z"
|
||||
fill="#37303F"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1205.57 445.809L1205.55 391.824C1205.55 384.648 1210.59 375.923 1216.8 372.336L1263.53 345.356C1267.46 343.084 1274.54 343.189 1278.47 345.462L1325.22 372.467C1331.43 376.057 1336.47 384.784 1336.47 391.961L1336.49 445.946C1336.49 450.489 1333.04 456.667 1329.11 458.939L1282.38 485.919C1276.16 489.506 1266.09 489.503 1259.88 485.913L1213.13 458.909C1209.19 456.631 1205.57 450.361 1205.57 445.809Z"
|
||||
fill="#37303F"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1125.41 491.529L1125.4 437.544C1125.4 430.368 1130.43 421.643 1136.64 418.056L1183.37 391.076C1187.31 388.804 1194.38 388.91 1198.32 391.182L1245.06 418.187C1251.28 421.777 1256.32 430.505 1256.32 437.681L1256.33 491.666C1256.33 496.209 1252.88 502.387 1248.95 504.659L1202.22 531.639C1196.01 535.226 1185.93 535.223 1179.72 531.633L1132.97 504.629C1129.03 502.352 1125.41 496.081 1125.41 491.529Z"
|
||||
fill="#251D2B"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1045.26 536.625L1045.24 482.64C1045.24 475.464 1050.27 466.738 1056.49 463.152L1103.22 436.172C1107.15 433.9 1114.23 434.005 1118.16 436.278L1164.91 463.283C1171.12 466.873 1176.16 475.6 1176.16 482.777L1176.17 536.761C1176.18 541.305 1172.73 547.483 1168.79 549.755L1122.06 576.735C1115.85 580.322 1105.78 580.319 1099.56 576.729L1052.82 549.725C1048.88 547.447 1045.26 541.177 1045.26 536.625Z"
|
||||
fill="#37303F"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1052.82 368.108L1099.57 395.112C1105.78 398.702 1115.85 398.705 1122.07 395.118L1168.8 368.138C1172.73 365.866 1176.18 359.686 1176.18 355.143L1176.16 301.158C1176.16 293.982 1171.12 285.254 1164.91 281.664L1118.16 254.659C1114.23 252.387 1107.15 252.283 1103.22 254.555L1056.49 281.535C1050.28 285.122 1045.24 293.847 1045.24 301.023L1045.26 355.008C1045.26 359.56 1048.88 365.83 1052.82 368.108Z"
|
||||
fill="#37303F"
|
||||
|
||||
<!-- Exploding block cluster -->
|
||||
<g class="block-cluster">
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1018.44 635.715L1018.45 581.73C1018.46 574.554 1013.42 565.829 1007.21 562.242L960.479 535.262C956.544 532.99 949.469 533.096 945.535 535.368L898.79 562.373C892.576 565.963 887.537 574.691 887.535 581.867L887.52 635.852C887.519 640.395 890.967 646.574 894.902 648.845L941.632 675.825C947.845 679.412 957.918 679.409 964.132 675.819L1010.88 648.815C1014.82 646.538 1018.44 640.267 1018.44 635.715Z"
|
||||
fill="#37303F"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1098.58 681.434L1098.6 627.449C1098.6 620.273 1093.57 611.548 1087.35 607.961L1040.62 580.981C1036.69 578.709 1029.61 578.814 1025.68 581.087L978.934 608.092C972.72 611.682 967.681 620.409 967.679 627.586L967.665 681.57C967.664 686.114 971.111 692.292 975.046 694.564L1021.78 721.544C1027.99 725.131 1038.06 725.128 1044.28 721.538L1091.02 694.534C1094.96 692.256 1098.58 685.986 1098.58 681.434Z"
|
||||
fill="#251D2B"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1205.98 635.714L1205.97 581.73C1205.97 574.553 1211 565.828 1217.21 562.241L1263.94 535.261C1267.88 532.989 1274.95 533.095 1278.89 535.367L1325.63 562.372C1331.85 565.962 1336.89 574.69 1336.89 581.866L1336.9 635.851C1336.9 640.394 1333.46 646.573 1329.52 648.844L1282.79 675.824C1276.58 679.411 1266.5 679.408 1260.29 675.818L1213.54 648.814C1209.6 646.537 1205.98 640.266 1205.98 635.714Z"
|
||||
fill="#37303F"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1125.83 681.434L1125.81 627.45C1125.81 620.273 1130.84 611.548 1137.06 607.961L1183.79 580.981C1187.72 578.71 1194.8 578.815 1198.73 581.087L1245.48 608.092C1251.69 611.682 1256.73 620.41 1256.73 627.586L1256.75 681.571C1256.75 686.114 1253.3 692.293 1249.36 694.565L1202.63 721.545C1196.42 725.131 1186.35 725.128 1180.13 721.539L1133.39 694.534C1129.45 692.257 1125.83 685.987 1125.83 681.434Z"
|
||||
fill="#251D2B"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1045.67 726.53L1045.66 672.545C1045.65 665.369 1050.69 656.644 1056.9 653.057L1103.63 626.077C1107.57 623.805 1114.64 623.911 1118.57 626.183L1165.32 653.188C1171.53 656.778 1176.57 665.506 1176.57 672.682L1176.59 726.667C1176.59 731.21 1173.14 737.388 1169.21 739.66L1122.48 766.64C1116.26 770.227 1106.19 770.224 1099.98 766.634L1053.23 739.63C1049.29 737.353 1045.67 731.082 1045.67 726.53Z"
|
||||
fill="#37303F"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1175.17 536.369L1175.18 482.384C1175.19 475.208 1170.15 466.483 1163.94 462.896L1117.21 435.916C1113.27 433.644 1106.2 433.749 1102.27 436.022L1055.52 463.027C1049.31 466.617 1044.27 475.344 1044.27 482.521L1044.25 536.506C1044.25 541.049 1047.7 547.227 1051.63 549.499L1098.36 576.479C1104.58 580.066 1114.65 580.063 1120.86 576.473L1167.61 549.469C1171.55 547.191 1175.17 540.921 1175.17 536.369Z"
|
||||
fill="#251D2B"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1052.83 458.666L1099.57 485.671C1105.79 489.261 1115.86 489.263 1122.07 485.677L1168.8 458.697C1172.74 456.425 1176.19 450.245 1176.18 445.702L1176.17 391.717C1176.17 384.54 1171.13 375.812 1164.92 372.223L1118.17 345.218C1114.24 342.945 1107.16 342.842 1103.23 345.114L1056.5 372.094C1050.28 375.68 1045.25 384.405 1045.25 391.582L1045.27 445.566C1045.27 450.119 1048.89 456.389 1052.83 458.666Z"
|
||||
fill="#251D2B"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1247.76 420.64L1201.02 393.635C1194.81 390.045 1184.73 390.043 1178.52 393.629L1131.79 420.609C1127.85 422.881 1124.41 429.061 1124.41 433.604L1124.42 487.589C1124.43 494.766 1129.46 503.493 1135.68 507.083L1182.42 534.088C1186.36 536.361 1193.43 536.464 1197.37 534.192L1244.1 507.212C1250.31 503.626 1255.34 494.901 1255.34 487.724L1255.33 433.74C1255.33 429.187 1251.71 422.917 1247.76 420.64Z"
|
||||
fill="#251D2B"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M975.833 420.641L1022.58 393.636C1028.79 390.047 1038.87 390.044 1045.08 393.63L1091.81 420.61C1095.74 422.882 1099.19 429.062 1099.19 433.606L1099.17 487.59C1099.17 494.767 1094.13 503.495 1087.92 507.085L1041.17 534.089C1037.24 536.362 1030.17 536.465 1026.23 534.194L979.501 507.214C973.288 503.627 968.254 494.902 968.256 487.725L968.27 433.741C968.271 429.188 971.891 422.918 975.833 420.641Z"
|
||||
fill="#251D2B"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1018.03 536.368L1018.04 482.384C1018.04 475.207 1013.01 466.482 1006.8 462.895L960.065 435.915C956.13 433.644 949.055 433.749 945.121 436.021L898.376 463.026C892.162 466.616 887.123 475.344 887.121 482.52L887.106 536.505C887.105 541.048 890.553 547.227 894.488 549.499L941.218 576.479C947.431 580.065 957.504 580.062 963.718 576.473L1010.46 549.468C1014.4 547.191 1018.02 540.921 1018.03 536.368Z"
|
||||
fill="#251D2B"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1018.03 536.368L1018.04 482.384C1018.04 475.207 1013.01 466.482 1006.8 462.895L960.065 435.915C956.13 433.644 949.055 433.749 945.121 436.021L898.376 463.026C892.162 466.616 887.123 475.344 887.121 482.52L887.106 536.505C887.105 541.048 890.553 547.227 894.488 549.499L941.218 576.479C947.431 580.065 957.504 580.062 963.718 576.473L1010.46 549.468C1014.4 547.191 1018.02 540.921 1018.03 536.368Z"
|
||||
fill="#251D2B"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1175.17 536.369L1175.18 482.384C1175.19 475.208 1170.15 466.483 1163.94 462.896L1117.21 435.916C1113.27 433.644 1106.2 433.749 1102.27 436.022L1055.52 463.027C1049.31 466.617 1044.27 475.344 1044.27 482.521L1044.25 536.506C1044.25 541.049 1047.7 547.227 1051.63 549.499L1098.36 576.479C1104.58 580.066 1114.65 580.063 1120.86 576.473L1167.61 549.469C1171.55 547.191 1175.17 540.921 1175.17 536.369Z"
|
||||
fill="#251D2B"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1175.17 536.369L1175.18 482.384C1175.19 475.208 1170.15 466.483 1163.94 462.896L1117.21 435.916C1113.27 433.644 1106.2 433.749 1102.27 436.022L1055.52 463.027C1049.31 466.617 1044.27 475.344 1044.27 482.521L1044.25 536.506C1044.25 541.049 1047.7 547.227 1051.63 549.499L1098.36 576.479C1104.58 580.066 1114.65 580.063 1120.86 576.473L1167.61 549.469C1171.55 547.191 1175.17 540.921 1175.17 536.369Z"
|
||||
fill="#251D2B"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1098.18 582.085L1098.19 528.1C1098.19 520.924 1093.16 512.199 1086.95 508.612L1040.22 481.632C1036.28 479.36 1029.21 479.465 1025.27 481.738L978.528 508.743C972.314 512.333 967.275 521.061 967.273 528.237L967.259 582.222C967.257 586.765 970.705 592.943 974.64 595.215L1021.37 622.195C1027.58 625.782 1037.66 625.779 1043.87 622.189L1090.62 595.185C1094.56 592.907 1098.18 586.637 1098.18 582.085Z"
|
||||
fill="#251D2B"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1098.18 582.085L1098.19 528.1C1098.19 520.924 1093.16 512.199 1086.95 508.612L1040.22 481.632C1036.28 479.36 1029.21 479.465 1025.27 481.738L978.528 508.743C972.314 512.333 967.275 521.061 967.273 528.237L967.259 582.222C967.257 586.765 970.705 592.943 974.64 595.215L1021.37 622.195C1027.58 625.782 1037.66 625.779 1043.87 622.189L1090.62 595.185C1094.56 592.907 1098.18 586.637 1098.18 582.085Z"
|
||||
fill="#251D2B"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1205.57 536.367L1205.56 482.383C1205.56 475.206 1210.59 466.481 1216.8 462.894L1263.53 435.914C1267.47 433.643 1274.54 433.748 1278.48 436.02L1325.22 463.025C1331.44 466.615 1336.48 475.343 1336.48 482.519L1336.49 536.504C1336.49 541.047 1333.04 547.226 1329.11 549.497L1282.38 576.477C1276.17 580.064 1266.09 580.061 1259.88 576.471L1213.13 549.467C1209.19 547.19 1205.57 540.919 1205.57 536.367Z"
|
||||
fill="#251D2B"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1205.57 536.367L1205.56 482.383C1205.56 475.206 1210.59 466.481 1216.8 462.894L1263.53 435.914C1267.47 433.643 1274.54 433.748 1278.48 436.02L1325.22 463.025C1331.44 466.615 1336.48 475.343 1336.48 482.519L1336.49 536.504C1336.49 541.047 1333.04 547.226 1329.11 549.497L1282.38 576.477C1276.17 580.064 1266.09 580.061 1259.88 576.471L1213.13 549.467C1209.19 547.19 1205.57 540.919 1205.57 536.367Z"
|
||||
fill="#251D2B"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1125.42 582.085L1125.4 528.101C1125.4 520.924 1130.43 512.199 1136.65 508.613L1183.38 481.633C1187.31 479.361 1194.39 479.466 1198.32 481.739L1245.07 508.743C1251.28 512.333 1256.32 521.061 1256.32 528.238L1256.34 582.222C1256.34 586.766 1252.89 592.944 1248.95 595.216L1202.22 622.196C1196.01 625.782 1185.94 625.78 1179.72 622.19L1132.98 595.185C1129.04 592.908 1125.42 586.638 1125.42 582.085Z"
|
||||
fill="#251D2B"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1125.42 582.085L1125.4 528.101C1125.4 520.924 1130.43 512.199 1136.65 508.613L1183.38 481.633C1187.31 479.361 1194.39 479.466 1198.32 481.739L1245.07 508.743C1251.28 512.333 1256.32 521.061 1256.32 528.238L1256.34 582.222C1256.34 586.766 1252.89 592.944 1248.95 595.216L1202.22 622.196C1196.01 625.782 1185.94 625.78 1179.72 622.19L1132.98 595.185C1129.04 592.908 1125.42 586.638 1125.42 582.085Z"
|
||||
fill="#251D2B"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1045.26 627.181L1045.25 573.197C1045.24 566.02 1050.28 557.295 1056.49 553.709L1103.22 526.729C1107.16 524.457 1114.23 524.562 1118.16 526.835L1164.91 553.839C1171.12 557.429 1176.16 566.157 1176.16 573.333L1176.18 627.318C1176.18 631.862 1172.73 638.04 1168.8 640.312L1122.07 667.292C1115.85 670.878 1105.78 670.876 1099.57 667.286L1052.82 640.281C1048.88 638.004 1045.26 631.734 1045.26 627.181Z"
|
||||
fill="#251D2B"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1045.26 627.181L1045.25 573.197C1045.24 566.02 1050.28 557.295 1056.49 553.709L1103.22 526.729C1107.16 524.457 1114.23 524.562 1118.16 526.835L1164.91 553.839C1171.12 557.429 1176.16 566.157 1176.16 573.333L1176.18 627.318C1176.18 631.862 1172.73 638.04 1168.8 640.312L1122.07 667.292C1115.85 670.878 1105.78 670.876 1099.57 667.286L1052.82 640.281C1048.88 638.004 1045.26 631.734 1045.26 627.181Z"
|
||||
fill="#251D2B"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1175.17 536.372L1175.19 482.387C1175.19 475.211 1170.16 466.485 1163.94 462.899L1117.21 435.919C1113.28 433.647 1106.2 433.752 1102.27 436.025L1055.52 463.03C1049.31 466.62 1044.27 475.347 1044.27 482.524L1044.25 536.508C1044.25 541.052 1047.7 547.23 1051.64 549.502L1098.37 576.482C1104.58 580.069 1114.65 580.066 1120.87 576.476L1167.61 549.472C1171.55 547.194 1175.17 540.924 1175.17 536.372Z"
|
||||
fill="#251D2B"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1098.18 582.085L1098.2 528.1C1098.2 520.924 1093.16 512.198 1086.95 508.612L1040.22 481.632C1036.29 479.36 1029.21 479.465 1025.28 481.738L978.532 508.743C972.318 512.333 967.279 521.06 967.277 528.237L967.263 582.221C967.261 586.765 970.709 592.943 974.644 595.215L1021.37 622.195C1027.59 625.782 1037.66 625.779 1043.87 622.189L1090.62 595.185C1094.56 592.907 1098.18 586.637 1098.18 582.085Z"
|
||||
fill="#F2FF59"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1125.42 582.085L1125.41 528.1C1125.4 520.924 1130.44 512.198 1136.65 508.612L1183.38 481.632C1187.32 479.36 1194.39 479.465 1198.32 481.738L1245.07 508.743C1251.28 512.333 1256.32 521.06 1256.32 528.237L1256.34 582.221C1256.34 586.765 1252.89 592.943 1248.96 595.215L1202.23 622.195C1196.01 625.782 1185.94 625.779 1179.73 622.189L1132.98 595.184C1129.04 592.907 1125.42 586.637 1125.42 582.085Z"
|
||||
fill="#F2FF59"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1045.26 627.173L1045.25 573.188C1045.25 566.012 1050.28 557.286 1056.49 553.7L1103.22 526.72C1107.16 524.448 1114.23 524.553 1118.17 526.826L1164.91 553.831C1171.13 557.42 1176.17 566.148 1176.17 573.325L1176.18 627.309C1176.18 631.853 1172.74 638.031 1168.8 640.303L1122.07 667.283C1115.86 670.87 1105.79 670.867 1099.57 667.277L1052.83 640.272C1048.88 637.995 1045.26 631.725 1045.26 627.173Z"
|
||||
fill="#251D2B"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1175.17 445.81L1175.18 391.826C1175.18 384.649 1170.15 375.924 1163.94 372.337L1117.21 345.357C1113.27 343.086 1106.2 343.191 1102.26 345.464L1055.52 372.468C1049.3 376.058 1044.26 384.786 1044.26 391.962L1044.25 445.947C1044.25 450.49 1047.69 456.669 1051.63 458.941L1098.36 485.921C1104.57 489.507 1114.64 489.505 1120.86 485.915L1167.6 458.91C1171.55 456.633 1175.17 450.363 1175.17 445.81Z"
|
||||
fill="#251D2B"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1052.82 368.108L1099.57 395.112C1105.78 398.702 1115.85 398.705 1122.07 395.118L1168.8 368.138C1172.73 365.866 1176.18 359.686 1176.18 355.143L1176.16 301.158C1176.16 293.982 1171.12 285.254 1164.91 281.664L1118.16 254.659C1114.23 252.387 1107.15 252.283 1103.22 254.555L1056.49 281.535C1050.28 285.122 1045.24 293.847 1045.24 301.023L1045.26 355.008C1045.26 359.56 1048.88 365.83 1052.82 368.108Z"
|
||||
fill="#37303F"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1247.76 330.081L1201.02 303.077C1194.81 299.487 1184.73 299.484 1178.52 303.071L1131.79 330.051C1127.85 332.322 1124.41 338.502 1124.41 343.046L1124.42 397.031C1124.43 404.207 1129.46 412.935 1135.67 416.525L1182.42 443.529C1186.35 445.802 1193.43 445.906 1197.36 443.634L1244.09 416.654C1250.31 413.067 1255.34 404.342 1255.34 397.166L1255.32 343.181C1255.32 338.629 1251.7 332.358 1247.76 330.081Z"
|
||||
fill="#251D2B"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M975.826 330.082L1022.57 303.078C1028.78 299.488 1038.86 299.485 1045.07 303.072L1091.8 330.052C1095.74 332.323 1099.18 338.503 1099.18 343.047L1099.17 397.032C1099.16 404.208 1094.13 412.936 1087.91 416.526L1041.17 443.53C1037.23 445.803 1030.16 445.907 1026.22 443.635L979.493 416.655C973.281 413.068 968.246 404.343 968.248 397.167L968.262 343.182C968.263 338.630 971.884 332.359 975.826 330.082Z"
|
||||
fill="#251D2B"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1018.02 445.809L1018.04 391.825C1018.04 384.649 1013 375.923 1006.79 372.337L960.061 345.357C956.126 343.085 949.051 343.19 945.117 345.463L898.372 372.468C892.158 376.057 887.119 384.785 887.117 391.962L887.103 445.946C887.101 450.49 890.549 456.668 894.484 458.94L941.215 485.92C947.427 489.507 957.5 489.504 963.714 485.914L1010.46 458.909C1014.4 456.632 1018.02 450.362 1018.02 445.809Z"
|
||||
fill="#37303F"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1175.17 445.81L1175.18 391.826C1175.18 384.649 1170.15 375.924 1163.94 372.337L1117.21 345.357C1113.27 343.086 1106.2 343.191 1102.26 345.464L1055.52 372.468C1049.3 376.058 1044.26 384.786 1044.26 391.962L1044.25 445.947C1044.25 450.49 1047.69 456.669 1051.63 458.941L1098.36 485.921C1104.57 489.507 1114.64 489.505 1120.86 485.915L1167.6 458.91C1171.55 456.633 1175.17 450.363 1175.17 445.81Z"
|
||||
fill="#F2FF59"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1098.17 491.528L1098.18 437.544C1098.19 430.367 1093.15 421.642 1086.94 418.056L1040.21 391.076C1036.27 388.804 1029.2 388.909 1025.27 391.182L978.52 418.186C972.306 421.776 967.267 430.504 967.265 437.681L967.251 491.665C967.25 496.209 970.697 502.387 974.632 504.659L1021.36 531.639C1027.58 535.225 1037.65 535.223 1043.86 531.633L1090.61 504.628C1094.55 502.351 1098.17 496.081 1098.17 491.528Z"
|
||||
fill="#251D2B"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1205.57 445.809L1205.55 391.824C1205.55 384.648 1210.59 375.923 1216.8 372.336L1263.53 345.356C1267.46 343.084 1274.54 343.189 1278.47 345.462L1325.22 372.467C1331.43 376.057 1336.47 384.784 1336.47 391.961L1336.49 445.946C1336.49 450.489 1333.04 456.667 1329.11 458.939L1282.38 485.919C1276.16 489.506 1266.09 489.503 1259.88 485.913L1213.13 458.909C1209.19 456.631 1205.57 450.361 1205.57 445.809Z"
|
||||
fill="#37303F"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1125.41 491.529L1125.4 437.544C1125.4 430.368 1130.43 421.643 1136.64 418.056L1183.37 391.076C1187.31 388.804 1194.38 388.910 1198.32 391.182L1245.06 418.187C1251.28 421.777 1256.32 430.505 1256.32 437.681L1256.33 491.666C1256.33 496.209 1252.88 502.387 1248.95 504.659L1202.22 531.639C1196.01 535.226 1185.93 535.223 1179.72 531.633L1132.97 504.629C1129.03 502.352 1125.41 496.081 1125.41 491.529Z"
|
||||
fill="#251D2B"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1045.26 536.625L1045.24 482.64C1045.24 475.464 1050.27 466.738 1056.49 463.152L1103.22 436.172C1107.15 433.9 1114.23 434.005 1118.16 436.278L1164.91 463.283C1171.12 466.873 1176.16 475.6 1176.16 482.777L1176.17 536.761C1176.18 541.305 1172.73 547.483 1168.79 549.755L1122.06 576.735C1115.85 580.322 1105.78 580.319 1099.56 576.729L1052.82 549.725C1048.88 547.447 1045.26 541.177 1045.26 536.625Z"
|
||||
fill="#37303F"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<!-- Left-edge fade -->
|
||||
<rect
|
||||
width="422.621"
|
||||
height="1125.11"
|
||||
transform="matrix(-1 0 0 1 909.219 9.26587)"
|
||||
fill="url(#enterpriseHeroFade)"
|
||||
style="pointer-events: none"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<!-- Left-edge fade -->
|
||||
<rect
|
||||
width="422.621"
|
||||
height="1125.11"
|
||||
transform="matrix(-1 0 0 1 909.219 9.26587)"
|
||||
fill="url(#enterpriseHeroFade)"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="enterpriseHeroFade"
|
||||
@@ -212,6 +353,9 @@ onMounted(() => {
|
||||
<stop stop-color="#211927" stop-opacity="0" />
|
||||
<stop offset="1" stop-color="#211927" />
|
||||
</linearGradient>
|
||||
<clipPath id="enterpriseHeroClip">
|
||||
<rect width="1600" height="1046" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
@@ -255,13 +399,13 @@ onMounted(() => {
|
||||
animation: ripple-effect 4s linear infinite;
|
||||
}
|
||||
|
||||
.ripple-delay-1 {
|
||||
.delay-1 {
|
||||
animation-delay: -1s;
|
||||
}
|
||||
.ripple-delay-2 {
|
||||
.delay-2 {
|
||||
animation-delay: -2s;
|
||||
}
|
||||
.ripple-delay-3 {
|
||||
.delay-3 {
|
||||
animation-delay: -3s;
|
||||
}
|
||||
|
||||
@@ -281,6 +425,11 @@ onMounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.block-cluster {
|
||||
transform-origin: center;
|
||||
transform-box: fill-box;
|
||||
}
|
||||
|
||||
.block-piece {
|
||||
transform-origin: center;
|
||||
transform-box: fill-box;
|
||||
|
||||
@@ -19,6 +19,8 @@ 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(
|
||||
@@ -26,24 +28,27 @@ export function useParallax(
|
||||
options: ParallaxOptions = {}
|
||||
) {
|
||||
const { fromY = 0, y = 200 } = options
|
||||
let ctx: gsap.Context | undefined
|
||||
let ctx: gsap.Context | gsap.MatchMedia | 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 trigger = triggerEl ?? els[0]
|
||||
const scrollTrigger = {
|
||||
trigger,
|
||||
start: options.start ?? 'top bottom',
|
||||
end: options.end ?? 'bottom top',
|
||||
scrub: 1
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
ctx = gsap.context(() => {
|
||||
els.forEach((el) => {
|
||||
gsap.fromTo(
|
||||
el,
|
||||
@@ -51,7 +56,15 @@ 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(() => {
|
||||
|
||||
@@ -27,6 +27,7 @@ export function getRoutes(locale: Locale = 'en'): Routes {
|
||||
}
|
||||
|
||||
export const externalLinks = {
|
||||
apiKeys: 'https://platform.comfy.org/profile/api-keys',
|
||||
blog: 'https://blog.comfy.org/',
|
||||
cloud: 'https://cloud.comfy.org',
|
||||
discord: 'https://discord.com/invite/comfyorg',
|
||||
|
||||
169
apps/website/src/data/ashby-roles.snapshot.json
Normal file
169
apps/website/src/data/ashby-roles.snapshot.json
Normal file
@@ -0,0 +1,169 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
18
apps/website/src/data/roles.ts
Normal file
18
apps/website/src/data/roles.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
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,6 +1505,10 @@ 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' },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import HeroSection from '../components/about/HeroSection.vue'
|
||||
import StorySection from '../components/about/StorySection.vue'
|
||||
import OurValuesSection from '../components/about/OurValuesSection.vue'
|
||||
import ValuesSection from '../components/about/ValuesSection.vue'
|
||||
import CareersSection from '../components/about/CareersSection.vue'
|
||||
@@ -8,6 +9,7 @@ import CareersSection from '../components/about/CareersSection.vue'
|
||||
|
||||
<BaseLayout title="About Us — Comfy">
|
||||
<HeroSection client:load />
|
||||
<StorySection />
|
||||
<OurValuesSection />
|
||||
<ValuesSection client:visible />
|
||||
<CareersSection />
|
||||
|
||||
@@ -5,6 +5,20 @@ 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
|
||||
@@ -12,7 +26,7 @@ import FAQSection from '../components/common/FAQSection.vue'
|
||||
description="Join the team building the operating system for generative AI. Open roles in engineering, design, marketing, and more."
|
||||
>
|
||||
<HeroSection />
|
||||
<RolesSection client:visible />
|
||||
<RolesSection departments={departments} client:visible />
|
||||
<WhyJoinSection client:visible />
|
||||
<TeamPhotosSection client:visible />
|
||||
<FAQSection
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import HeroSection from '../../components/about/HeroSection.vue'
|
||||
import StorySection from '../../components/about/StorySection.vue'
|
||||
import OurValuesSection from '../../components/about/OurValuesSection.vue'
|
||||
import ValuesSection from '../../components/about/ValuesSection.vue'
|
||||
import CareersSection from '../../components/about/CareersSection.vue'
|
||||
@@ -8,6 +9,7 @@ import CareersSection from '../../components/about/CareersSection.vue'
|
||||
|
||||
<BaseLayout title="关于我们 — Comfy" description="了解 ComfyUI 背后的团队和使命——开源的生成式 AI 平台。">
|
||||
<HeroSection locale="zh-CN" client:load />
|
||||
<StorySection locale="zh-CN" />
|
||||
<OurValuesSection locale="zh-CN" />
|
||||
<ValuesSection locale="zh-CN" client:visible />
|
||||
<CareersSection locale="zh-CN" />
|
||||
|
||||
@@ -5,6 +5,20 @@ 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
|
||||
@@ -12,7 +26,7 @@ import FAQSection from '../../components/common/FAQSection.vue'
|
||||
description="加入构建生成式 AI 操作系统的团队。工程、设计、市场营销等岗位开放招聘中。"
|
||||
>
|
||||
<HeroSection locale="zh-CN" />
|
||||
<RolesSection locale="zh-CN" client:visible />
|
||||
<RolesSection locale="zh-CN" departments={departments} client:visible />
|
||||
<WhyJoinSection locale="zh-CN" client:visible />
|
||||
<TeamPhotosSection client:visible />
|
||||
<FAQSection
|
||||
|
||||
130
apps/website/src/utils/ashby.ci.test.ts
Normal file
130
apps/website/src/utils/ashby.ci.test.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
113
apps/website/src/utils/ashby.ci.ts
Normal file
113
apps/website/src/utils/ashby.ci.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
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`
|
||||
}
|
||||
17
apps/website/src/utils/ashby.schema.ts
Normal file
17
apps/website/src/utils/ashby.schema.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
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>
|
||||
328
apps/website/src/utils/ashby.test.ts
Normal file
328
apps/website/src/utils/ashby.test.ts
Normal file
@@ -0,0 +1,328 @@
|
||||
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 })
|
||||
})
|
||||
})
|
||||
299
apps/website/src/utils/ashby.ts
Normal file
299
apps/website/src/utils/ashby.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
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,8 +8,10 @@
|
||||
"include": [
|
||||
"src/**/*",
|
||||
"e2e/**/*",
|
||||
"scripts/**/*",
|
||||
"astro.config.ts",
|
||||
"playwright.config.ts"
|
||||
"playwright.config.ts",
|
||||
"vitest.config.ts"
|
||||
],
|
||||
"exclude": ["src/**/*.stories.ts"],
|
||||
"references": [{ "path": "./tsconfig.stories.json" }]
|
||||
|
||||
9
apps/website/vitest.config.ts
Normal file
9
apps/website/vitest.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['src/**/*.{test,spec}.ts'],
|
||||
globals: false
|
||||
}
|
||||
})
|
||||
@@ -250,6 +250,26 @@ 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
|
||||
@@ -261,6 +281,13 @@ 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
|
||||
@@ -269,6 +296,8 @@ 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
|
||||
@@ -299,10 +328,17 @@ 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]'))
|
||||
@@ -334,6 +370,12 @@ 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 })
|
||||
}
|
||||
@@ -383,6 +425,29 @@ 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,6 +10,8 @@ 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)
|
||||
@@ -23,10 +25,10 @@ export class SubgraphBreadcrumbPanel {
|
||||
TestIds.breadcrumb.missingNodesIcon
|
||||
)
|
||||
this.blueprintTag = this.root.getByTestId(TestIds.breadcrumb.blueprintTag)
|
||||
}
|
||||
|
||||
rootItem(): Locator {
|
||||
return this.page.getByTestId(TestIds.breadcrumb.item('root'))
|
||||
this.rootItem = page.getByTestId(TestIds.breadcrumb.item('root'))
|
||||
this.rootBlueprintTag = this.rootItem.getByTestId(
|
||||
TestIds.breadcrumb.blueprintTag
|
||||
)
|
||||
}
|
||||
|
||||
subgraphItem(subgraphId: string): Locator {
|
||||
|
||||
@@ -7,26 +7,56 @@ 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 }
|
||||
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
|
||||
}
|
||||
): 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_${overrides.id}.png`,
|
||||
filename: `output_${rest.id}.${extension}`,
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
mediaType
|
||||
},
|
||||
outputs_count: 1,
|
||||
priority: 0,
|
||||
...overrides
|
||||
...rest
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +84,46 @@ 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']
|
||||
|
||||
232
browser_tests/tests/sidebar/assets-filter.spec.ts
Normal file
232
browser_tests/tests/sidebar/assets-filter.spec.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
206
browser_tests/tests/sidebar/assets-sort.spec.ts
Normal file
206
browser_tests/tests/sidebar/assets-sort.spec.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
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,3 +772,119 @@ 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,12 +265,65 @@ 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,6 +196,48 @@ 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(
|
||||
@@ -813,6 +855,339 @@ 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.9",
|
||||
"version": "1.44.11",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
61
pnpm-lock.yaml
generated
61
pnpm-lock.yaml
generated
@@ -952,6 +952,9 @@ importers:
|
||||
vue:
|
||||
specifier: 'catalog:'
|
||||
version: 3.5.13(typescript@5.9.3)
|
||||
zod:
|
||||
specifier: 'catalog:'
|
||||
version: 3.25.76
|
||||
devDependencies:
|
||||
'@astrojs/check':
|
||||
specifier: 'catalog:'
|
||||
@@ -971,9 +974,15 @@ 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:
|
||||
@@ -14105,6 +14114,14 @@ 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
|
||||
@@ -14139,7 +14156,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@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: 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/utils@3.2.4':
|
||||
dependencies:
|
||||
@@ -20320,6 +20337,48 @@ 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,19 +3,43 @@
|
||||
<label class="content-center text-xs text-node-component-slot-text">
|
||||
{{ $t('boundingBox.x') }}
|
||||
</label>
|
||||
<ScrubableNumberInput v-model="x" :min="0" :step="1" :disabled />
|
||||
<ScrubableNumberInput
|
||||
v-model="x"
|
||||
:min="0"
|
||||
:step="1"
|
||||
:disabled
|
||||
data-testid="bounding-box-x"
|
||||
/>
|
||||
<label class="content-center text-xs text-node-component-slot-text">
|
||||
{{ $t('boundingBox.y') }}
|
||||
</label>
|
||||
<ScrubableNumberInput v-model="y" :min="0" :step="1" :disabled />
|
||||
<ScrubableNumberInput
|
||||
v-model="y"
|
||||
:min="0"
|
||||
:step="1"
|
||||
:disabled
|
||||
data-testid="bounding-box-y"
|
||||
/>
|
||||
<label class="content-center text-xs text-node-component-slot-text">
|
||||
{{ $t('boundingBox.width') }}
|
||||
</label>
|
||||
<ScrubableNumberInput v-model="width" :min="1" :step="1" :disabled />
|
||||
<ScrubableNumberInput
|
||||
v-model="width"
|
||||
:min="1"
|
||||
:step="1"
|
||||
:disabled
|
||||
data-testid="bounding-box-width"
|
||||
/>
|
||||
<label class="content-center text-xs text-node-component-slot-text">
|
||||
{{ $t('boundingBox.height') }}
|
||||
</label>
|
||||
<ScrubableNumberInput v-model="height" :min="1" :step="1" :disabled />
|
||||
<ScrubableNumberInput
|
||||
v-model="height"
|
||||
:min="1"
|
||||
:step="1"
|
||||
:disabled
|
||||
data-testid="bounding-box-height"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
<Tag
|
||||
v-if="item.isBlueprint"
|
||||
data-testid="subgraph-breadcrumb-blueprint-tag"
|
||||
value="Blueprint"
|
||||
:value="t('breadcrumbsMenu.blueprint')"
|
||||
severity="primary"
|
||||
/>
|
||||
<i v-if="isActive" class="pi pi-angle-down text-2xs"></i>
|
||||
|
||||
253
src/components/curve/WidgetCurve.test.ts
Normal file
253
src/components/curve/WidgetCurve.test.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
/* 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
178
src/components/maskeditor/BrushCursor.test.ts
Normal file
178
src/components/maskeditor/BrushCursor.test.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
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,6 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
id="maskEditor_brush"
|
||||
data-testid="brush-cursor"
|
||||
:style="{
|
||||
position: 'absolute',
|
||||
opacity: brushOpacity,
|
||||
@@ -15,6 +16,7 @@
|
||||
>
|
||||
<div
|
||||
id="maskEditor_brushPreviewGradient"
|
||||
data-testid="brush-cursor-gradient"
|
||||
:style="{
|
||||
display: gradientVisible ? 'block' : 'none',
|
||||
background: gradientBackground
|
||||
|
||||
230
src/components/maskeditor/BrushSettingsPanel.test.ts
Normal file
230
src/components/maskeditor/BrushSettingsPanel.test.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
/* 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
168
src/components/maskeditor/ColorSelectSettingsPanel.test.ts
Normal file
168
src/components/maskeditor/ColorSelectSettingsPanel.test.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
281
src/components/maskeditor/ImageLayerSettingsPanel.test.ts
Normal file
281
src/components/maskeditor/ImageLayerSettingsPanel.test.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
/* 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('')
|
||||
})
|
||||
})
|
||||
})
|
||||
354
src/components/maskeditor/MaskEditorContent.test.ts
Normal file
354
src/components/maskeditor/MaskEditorContent.test.ts
Normal file
@@ -0,0 +1,354 @@
|
||||
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,6 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
ref="containerRef"
|
||||
data-testid="mask-editor-root"
|
||||
class="maskEditor-dialog-root flex size-full flex-col"
|
||||
@contextmenu.prevent
|
||||
@dragstart="handleDragStart"
|
||||
|
||||
87
src/components/maskeditor/PaintBucketSettingsPanel.test.ts
Normal file
87
src/components/maskeditor/PaintBucketSettingsPanel.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
184
src/components/maskeditor/PointerZone.test.ts
Normal file
184
src/components/maskeditor/PointerZone.test.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
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,6 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
ref="pointerZoneRef"
|
||||
data-testid="pointer-zone"
|
||||
class="h-full w-[calc(100%-4rem-220px)]"
|
||||
@pointerdown="handlePointerDown"
|
||||
@pointermove="handlePointerMove"
|
||||
|
||||
70
src/components/maskeditor/SettingsPanelContainer.test.ts
Normal file
70
src/components/maskeditor/SettingsPanelContainer.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
49
src/components/maskeditor/SidePanel.test.ts
Normal file
49
src/components/maskeditor/SidePanel.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
155
src/components/maskeditor/ToolPanel.test.ts
Normal file
155
src/components/maskeditor/ToolPanel.test.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
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,6 +4,8 @@
|
||||
<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 }
|
||||
@@ -23,8 +25,12 @@
|
||||
:title="t('maskEditor.clickToResetZoom')"
|
||||
@click="onResetZoom"
|
||||
>
|
||||
<span class="text-sm text-text-secondary">{{ zoomText }}</span>
|
||||
<span class="text-xs text-text-secondary">{{ dimensionsText }}</span>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
79
src/components/maskeditor/controls/DropdownControl.test.ts
Normal file
79
src/components/maskeditor/controls/DropdownControl.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
64
src/components/maskeditor/controls/SliderControl.test.ts
Normal file
64
src/components/maskeditor/controls/SliderControl.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
60
src/components/maskeditor/controls/ToggleControl.test.ts
Normal file
60
src/components/maskeditor/controls/ToggleControl.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
262
src/components/maskeditor/dialog/TopBarHeader.test.ts
Normal file
262
src/components/maskeditor/dialog/TopBarHeader.test.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
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'
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
380
src/composables/maskeditor/useCoordinateTransform.test.ts
Normal file
380
src/composables/maskeditor/useCoordinateTransform.test.ts
Normal file
@@ -0,0 +1,380 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
209
src/composables/maskeditor/useKeyboard.test.ts
Normal file
209
src/composables/maskeditor/useKeyboard.test.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
118
src/composables/maskeditor/useMaskEditor.test.ts
Normal file
118
src/composables/maskeditor/useMaskEditor.test.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
517
src/composables/maskeditor/useToolManager.test.ts
Normal file
517
src/composables/maskeditor/useToolManager.test.ts
Normal file
@@ -0,0 +1,517 @@
|
||||
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,6 +86,129 @@ 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,11 +47,15 @@ export const useNodeDragAndDrop = <T>(
|
||||
const installedDragOver = isDraggingFiles
|
||||
node.onDragOver = installedDragOver
|
||||
|
||||
const installedDragDrop = async function (e: DragEvent) {
|
||||
const installedDragDrop = async function (e: DragEvent, claimEvent = false) {
|
||||
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
|
||||
}
|
||||
@@ -59,6 +63,11 @@ 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,6 +241,259 @@ 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,7 +1,5 @@
|
||||
import * as THREE from 'three'
|
||||
|
||||
import { exceedsClickThreshold } from '@/composables/useClickDragGuard'
|
||||
|
||||
import { AnimationManager } from './AnimationManager'
|
||||
import { CameraManager } from './CameraManager'
|
||||
import { ControlsManager } from './ControlsManager'
|
||||
@@ -24,6 +22,10 @@ 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,
|
||||
@@ -47,7 +49,7 @@ function positionThumbnailCamera(
|
||||
class Load3d {
|
||||
renderer: THREE.WebGLRenderer
|
||||
protected clock: THREE.Clock
|
||||
protected animationFrameId: number | null = null
|
||||
private renderLoop: RenderLoopHandle | null = null
|
||||
private loadingPromise: Promise<void> | null = null
|
||||
private onContextMenuCallback?: (event: MouseEvent) => void
|
||||
private getDimensionsCallback?: () => { width: number; height: number } | null
|
||||
@@ -75,10 +77,7 @@ class Load3d {
|
||||
targetAspectRatio: number = 1
|
||||
isViewerMode: boolean = false
|
||||
|
||||
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 disposeContextMenuGuard: (() => void) | null = null
|
||||
private resizeObserver: ResizeObserver | null = null
|
||||
|
||||
constructor(container: Element | HTMLElement, options: Load3DOptions = {}) {
|
||||
@@ -214,69 +213,12 @@ 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 {
|
||||
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)
|
||||
}
|
||||
this.disposeContextMenuGuard = attachContextMenuGuard(
|
||||
this.renderer.domElement,
|
||||
(event) => this.onContextMenuCallback?.(event),
|
||||
{ isDisabled: () => this.isViewerMode }
|
||||
)
|
||||
}
|
||||
|
||||
getEventManager(): EventManager {
|
||||
@@ -354,22 +296,10 @@ class Load3d {
|
||||
}
|
||||
|
||||
if (this.shouldMaintainAspectRatio()) {
|
||||
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
|
||||
}
|
||||
const { offsetX, offsetY, width, height } = computeLetterboxedViewport(
|
||||
{ width: containerWidth, height: containerHeight },
|
||||
this.targetAspectRatio
|
||||
)
|
||||
|
||||
this.renderer.setViewport(0, 0, containerWidth, containerHeight)
|
||||
this.renderer.setScissor(0, 0, containerWidth, containerHeight)
|
||||
@@ -377,11 +307,10 @@ class Load3d {
|
||||
this.renderer.setClearColor(0x0a0a0a)
|
||||
this.renderer.clear()
|
||||
|
||||
this.renderer.setViewport(offsetX, offsetY, renderWidth, renderHeight)
|
||||
this.renderer.setScissor(offsetX, offsetY, renderWidth, renderHeight)
|
||||
this.renderer.setViewport(offsetX, offsetY, width, height)
|
||||
this.renderer.setScissor(offsetX, offsetY, width, height)
|
||||
|
||||
const renderAspectRatio = renderWidth / renderHeight
|
||||
this.cameraManager.updateAspectRatio(renderAspectRatio)
|
||||
this.cameraManager.updateAspectRatio(width / height)
|
||||
} else {
|
||||
// No aspect ratio constraint: fill the entire container
|
||||
this.renderer.setViewport(0, 0, containerWidth, containerHeight)
|
||||
@@ -422,28 +351,23 @@ class Load3d {
|
||||
}
|
||||
|
||||
private startAnimation(): void {
|
||||
const animate = () => {
|
||||
this.animationFrameId = requestAnimationFrame(animate)
|
||||
this.renderLoop = startRenderLoop({
|
||||
tick: () => {
|
||||
const delta = this.clock.getDelta()
|
||||
this.animationManager.update(delta)
|
||||
this.viewHelperManager.update(delta)
|
||||
this.controlsManager.update()
|
||||
|
||||
if (!this.isActive()) {
|
||||
return
|
||||
}
|
||||
this.renderMainScene()
|
||||
|
||||
const delta = this.clock.getDelta()
|
||||
this.animationManager.update(delta)
|
||||
this.viewHelperManager.update(delta)
|
||||
this.controlsManager.update()
|
||||
this.resetViewport()
|
||||
|
||||
this.renderMainScene()
|
||||
|
||||
this.resetViewport()
|
||||
|
||||
if (this.viewHelperManager.viewHelper.render) {
|
||||
this.viewHelperManager.viewHelper.render(this.renderer)
|
||||
}
|
||||
}
|
||||
|
||||
animate()
|
||||
if (this.viewHelperManager.viewHelper.render) {
|
||||
this.viewHelperManager.viewHelper.render(this.renderer)
|
||||
}
|
||||
},
|
||||
isActive: () => this.isActive()
|
||||
})
|
||||
}
|
||||
|
||||
updateStatusMouseOnNode(onNode: boolean): void {
|
||||
@@ -459,14 +383,14 @@ class Load3d {
|
||||
}
|
||||
|
||||
isActive(): boolean {
|
||||
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
|
||||
)
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
async exportModel(format: string): Promise<void> {
|
||||
@@ -527,24 +451,16 @@ class Load3d {
|
||||
const containerHeight = this.renderer.domElement.clientHeight
|
||||
|
||||
if (this.shouldMaintainAspectRatio()) {
|
||||
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
|
||||
}
|
||||
const { width, height } = computeLetterboxedViewport(
|
||||
{ width: containerWidth, height: containerHeight },
|
||||
this.targetAspectRatio
|
||||
)
|
||||
|
||||
this.sceneManager.updateBackgroundSize(
|
||||
this.sceneManager.backgroundTexture,
|
||||
this.sceneManager.backgroundMesh,
|
||||
renderWidth,
|
||||
renderHeight
|
||||
width,
|
||||
height
|
||||
)
|
||||
} else {
|
||||
// No aspect ratio constraints: fill container
|
||||
@@ -651,11 +567,11 @@ class Load3d {
|
||||
}
|
||||
|
||||
isSplatModel(): boolean {
|
||||
return this.modelManager.containsSplatMesh()
|
||||
return this.loaderManager.getCurrentAdapter()?.kind === 'splat'
|
||||
}
|
||||
|
||||
isPlyModel(): boolean {
|
||||
return this.modelManager.originalModel instanceof THREE.BufferGeometry
|
||||
return this.loaderManager.getCurrentAdapter()?.kind === 'pointCloud'
|
||||
}
|
||||
|
||||
clearModel(): void {
|
||||
@@ -742,21 +658,14 @@ class Load3d {
|
||||
}
|
||||
|
||||
if (this.shouldMaintainAspectRatio()) {
|
||||
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
|
||||
}
|
||||
const { width, height } = computeLetterboxedViewport(
|
||||
{ width: containerWidth, height: containerHeight },
|
||||
this.targetAspectRatio
|
||||
)
|
||||
|
||||
this.renderer.setSize(containerWidth, containerHeight)
|
||||
this.cameraManager.handleResize(renderWidth, renderHeight)
|
||||
this.sceneManager.handleResize(renderWidth, renderHeight)
|
||||
this.cameraManager.handleResize(width, height)
|
||||
this.sceneManager.handleResize(width, height)
|
||||
} else {
|
||||
// No aspect ratio constraint: use container dimensions directly
|
||||
this.renderer.setSize(containerWidth, containerHeight)
|
||||
@@ -945,10 +854,8 @@ class Load3d {
|
||||
this.resizeObserver = null
|
||||
}
|
||||
|
||||
if (this.contextMenuAbortController) {
|
||||
this.contextMenuAbortController.abort()
|
||||
this.contextMenuAbortController = null
|
||||
}
|
||||
this.disposeContextMenuGuard?.()
|
||||
this.disposeContextMenuGuard = null
|
||||
|
||||
this.renderer.forceContextLoss()
|
||||
const canvas = this.renderer.domElement
|
||||
@@ -958,9 +865,8 @@ class Load3d {
|
||||
})
|
||||
canvas.dispatchEvent(event)
|
||||
|
||||
if (this.animationFrameId !== null) {
|
||||
cancelAnimationFrame(this.animationFrameId)
|
||||
}
|
||||
this.renderLoop?.stop()
|
||||
this.renderLoop = null
|
||||
|
||||
this.sceneManager.dispose()
|
||||
this.cameraManager.dispose()
|
||||
|
||||
530
src/extensions/core/load3d/LoaderManager.test.ts
Normal file
530
src/extensions/core/load3d/LoaderManager.test.ts
Normal file
@@ -0,0 +1,530 @@
|
||||
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,60 +1,49 @@
|
||||
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 type * as THREE from 'three'
|
||||
|
||||
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'
|
||||
import { FastPLYLoader } from './loader/FastPLYLoader'
|
||||
|
||||
/**
|
||||
* 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()
|
||||
]
|
||||
}
|
||||
|
||||
export class LoaderManager implements LoaderManagerInterface {
|
||||
gltfLoader: GLTFLoader
|
||||
objLoader: OBJLoader2Parallel
|
||||
mtlLoader: MTLLoader
|
||||
fbxLoader: FBXLoader
|
||||
stlLoader: STLLoader
|
||||
plyLoader: PLYLoader
|
||||
fastPlyLoader: FastPLYLoader
|
||||
|
||||
private modelManager: ModelManagerInterface
|
||||
private eventManager: EventManagerInterface
|
||||
private readonly modelManager: ModelManagerInterface
|
||||
private readonly eventManager: EventManagerInterface
|
||||
private readonly adapters: ModelAdapter[]
|
||||
private currentLoadId: number = 0
|
||||
private _currentAdapter: ModelAdapter | null = null
|
||||
|
||||
constructor(
|
||||
modelManager: ModelManagerInterface,
|
||||
eventManager: EventManagerInterface
|
||||
eventManager: EventManagerInterface,
|
||||
adapters?: readonly ModelAdapter[]
|
||||
) {
|
||||
this.modelManager = modelManager
|
||||
this.eventManager = eventManager
|
||||
this.adapters = adapters ? [...adapters] : defaultAdapters()
|
||||
}
|
||||
|
||||
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()
|
||||
getCurrentAdapter(): ModelAdapter | null {
|
||||
return this._currentAdapter
|
||||
}
|
||||
|
||||
init(): void {}
|
||||
@@ -68,6 +57,7 @@ export class LoaderManager implements LoaderManagerInterface {
|
||||
this.eventManager.emitEvent('modelLoadingStart', null)
|
||||
|
||||
this.modelManager.clearModel()
|
||||
this._currentAdapter = null
|
||||
|
||||
this.modelManager.originalURL = url
|
||||
|
||||
@@ -80,12 +70,9 @@ export class LoaderManager implements LoaderManagerInterface {
|
||||
} else {
|
||||
const filename = new URLSearchParams(url.split('?')[1]).get('filename')
|
||||
fileExtension = filename?.split('.').pop()?.toLowerCase()
|
||||
|
||||
if (filename) {
|
||||
this.modelManager.originalFileName = filename.split('.')[0] || 'model'
|
||||
} else {
|
||||
this.modelManager.originalFileName = 'model'
|
||||
}
|
||||
this.modelManager.originalFileName = filename
|
||||
? filename.split('.')[0] || 'model'
|
||||
: 'model'
|
||||
}
|
||||
|
||||
if (!fileExtension) {
|
||||
@@ -93,19 +80,21 @@ export class LoaderManager implements LoaderManagerInterface {
|
||||
return
|
||||
}
|
||||
|
||||
const model = await this.loadModelInternal(url, fileExtension)
|
||||
const result = await this.loadModelInternal(url, fileExtension)
|
||||
|
||||
if (loadId !== this.currentLoadId) {
|
||||
return
|
||||
}
|
||||
|
||||
if (model) {
|
||||
await this.modelManager.setupModel(model)
|
||||
if (result && result.model) {
|
||||
this._currentAdapter = result.adapter
|
||||
await this.modelManager.setupModel(result.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'))
|
||||
@@ -113,26 +102,50 @@ 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<THREE.Object3D | null> {
|
||||
let model: THREE.Object3D | null = null
|
||||
|
||||
): Promise<{ adapter: ModelAdapter; 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 +
|
||||
@@ -140,217 +153,10 @@ export class LoaderManager implements LoaderManagerInterface {
|
||||
encodeURIComponent(subfolder) +
|
||||
'&filename='
|
||||
|
||||
switch (fileExtension) {
|
||||
case 'stl':
|
||||
this.stlLoader.setPath(path)
|
||||
const geometry = await this.stlLoader.loadAsync(filename)
|
||||
this.modelManager.setOriginalModel(geometry)
|
||||
geometry.computeVertexNormals()
|
||||
const adapter = this.pickAdapter(fileExtension)
|
||||
if (!adapter) return null
|
||||
|
||||
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
|
||||
const model = await adapter.load(this.createLoadContext(), path, filename)
|
||||
return { adapter, model }
|
||||
}
|
||||
}
|
||||
|
||||
302
src/extensions/core/load3d/MeshModelAdapter.test.ts
Normal file
302
src/extensions/core/load3d/MeshModelAdapter.test.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
155
src/extensions/core/load3d/MeshModelAdapter.ts
Normal file
155
src/extensions/core/load3d/MeshModelAdapter.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
89
src/extensions/core/load3d/ModelAdapter.test.ts
Normal file
89
src/extensions/core/load3d/ModelAdapter.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
88
src/extensions/core/load3d/ModelAdapter.ts
Normal file
88
src/extensions/core/load3d/ModelAdapter.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
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()
|
||||
}
|
||||
116
src/extensions/core/load3d/PointCloudModelAdapter.test.ts
Normal file
116
src/extensions/core/load3d/PointCloudModelAdapter.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
120
src/extensions/core/load3d/PointCloudModelAdapter.ts
Normal file
120
src/extensions/core/load3d/PointCloudModelAdapter.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
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
|
||||
}
|
||||
82
src/extensions/core/load3d/SplatModelAdapter.test.ts
Normal file
82
src/extensions/core/load3d/SplatModelAdapter.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
38
src/extensions/core/load3d/SplatModelAdapter.ts
Normal file
38
src/extensions/core/load3d/SplatModelAdapter.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
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,11 +3,7 @@
|
||||
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 { 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'
|
||||
import type { GLTF } from 'three/examples/jsm/loaders/GLTFLoader'
|
||||
|
||||
export type MaterialMode =
|
||||
| 'original'
|
||||
@@ -203,12 +199,6 @@ 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>
|
||||
|
||||
129
src/extensions/core/load3d/load3dContextMenuGuard.test.ts
Normal file
129
src/extensions/core/load3d/load3dContextMenuGuard.test.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
72
src/extensions/core/load3d/load3dContextMenuGuard.ts
Normal file
72
src/extensions/core/load3d/load3dContextMenuGuard.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
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()
|
||||
}
|
||||
62
src/extensions/core/load3d/load3dRenderLoop.test.ts
Normal file
62
src/extensions/core/load3d/load3dRenderLoop.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
32
src/extensions/core/load3d/load3dRenderLoop.ts
Normal file
32
src/extensions/core/load3d/load3dRenderLoop.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
108
src/extensions/core/load3d/load3dViewport.test.ts
Normal file
108
src/extensions/core/load3d/load3dViewport.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
55
src/extensions/core/load3d/load3dViewport.ts
Normal file
55
src/extensions/core/load3d/load3dViewport.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
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
|
||||
)
|
||||
}
|
||||
@@ -579,7 +579,9 @@ export class LGraph
|
||||
for (let i = 0; i < num; i++) {
|
||||
for (let j = 0; j < limit; ++j) {
|
||||
const node = nodes[j]
|
||||
// FIXME: Looks like copy/paste broken logic - checks for "on", executes "do"
|
||||
if (node.mode == LGraphEventMode.ALWAYS && node.onExecute) {
|
||||
// wrap node.onExecute();
|
||||
node.doExecute?.()
|
||||
}
|
||||
}
|
||||
@@ -595,8 +597,8 @@ export class LGraph
|
||||
for (let i = 0; i < num; i++) {
|
||||
for (let j = 0; j < limit; ++j) {
|
||||
const node = nodes[j]
|
||||
if (node.mode == LGraphEventMode.ALWAYS && node.onExecute) {
|
||||
node.doExecute?.()
|
||||
if (node.mode == LGraphEventMode.ALWAYS) {
|
||||
node.onExecute?.()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -335,6 +335,7 @@
|
||||
},
|
||||
"breadcrumbsMenu": {
|
||||
"app": "التطبيق",
|
||||
"blueprint": "المخطط",
|
||||
"clearWorkflow": "مسح سير العمل",
|
||||
"deleteBlueprint": "حذف المخطط",
|
||||
"deleteWorkflow": "حذف سير العمل",
|
||||
@@ -918,6 +919,10 @@
|
||||
"showSwapNodes": "عرض العقد البديلة",
|
||||
"swapNodes": "يمكن استبدال بعض العقد ببدائل"
|
||||
},
|
||||
"errorPanelSurvey": {
|
||||
"ctaButton": "أعطِ ملاحظاتك",
|
||||
"ctaText": "ما رأيك في لوحة الأخطاء الجديدة؟"
|
||||
},
|
||||
"essentials": {
|
||||
"batchImage": "معالجة صور دفعة واحدة",
|
||||
"canny": "كانّي",
|
||||
|
||||
@@ -2734,6 +2734,7 @@
|
||||
"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",
|
||||
"description": "OpenAI video and audio generation.",
|
||||
"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.",
|
||||
"inputs": {
|
||||
"model": {
|
||||
"name": "model"
|
||||
|
||||
@@ -335,6 +335,7 @@
|
||||
},
|
||||
"breadcrumbsMenu": {
|
||||
"app": "Aplicación",
|
||||
"blueprint": "Plano",
|
||||
"clearWorkflow": "Limpiar flujo de trabajo",
|
||||
"deleteBlueprint": "Eliminar Plano",
|
||||
"deleteWorkflow": "Eliminar flujo de trabajo",
|
||||
@@ -918,6 +919,10 @@
|
||||
"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,6 +335,7 @@
|
||||
},
|
||||
"breadcrumbsMenu": {
|
||||
"app": "برنامه",
|
||||
"blueprint": "نقشه راه",
|
||||
"clearWorkflow": "پاکسازی workflow",
|
||||
"deleteBlueprint": "حذف blueprint",
|
||||
"deleteWorkflow": "حذف workflow",
|
||||
@@ -918,6 +919,10 @@
|
||||
"showSwapNodes": "نمایش نودهای قابل جایگزینی",
|
||||
"swapNodes": "برخی از نودها را میتوان با گزینههای جایگزین تعویض کرد"
|
||||
},
|
||||
"errorPanelSurvey": {
|
||||
"ctaButton": "ارسال بازخورد",
|
||||
"ctaText": "نظر شما درباره پنل خطا جدید چیست؟"
|
||||
},
|
||||
"essentials": {
|
||||
"batchImage": "پردازش دستهای تصویر",
|
||||
"canny": "لبهیابی Canny",
|
||||
|
||||
@@ -335,6 +335,7 @@
|
||||
},
|
||||
"breadcrumbsMenu": {
|
||||
"app": "Application",
|
||||
"blueprint": "Plan",
|
||||
"clearWorkflow": "Effacer le workflow",
|
||||
"deleteBlueprint": "Supprimer le plan",
|
||||
"deleteWorkflow": "Supprimer le workflow",
|
||||
@@ -918,6 +919,10 @@
|
||||
"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,6 +335,7 @@
|
||||
},
|
||||
"breadcrumbsMenu": {
|
||||
"app": "アプリ",
|
||||
"blueprint": "ブループリント",
|
||||
"clearWorkflow": "ワークフローをクリア",
|
||||
"deleteBlueprint": "ブループリントを削除",
|
||||
"deleteWorkflow": "ワークフローを削除",
|
||||
@@ -918,6 +919,10 @@
|
||||
"showSwapNodes": "代替可能なノードを表示",
|
||||
"swapNodes": "いくつかのノードは代替可能です"
|
||||
},
|
||||
"errorPanelSurvey": {
|
||||
"ctaButton": "フィードバックを送る",
|
||||
"ctaText": "新しいエラーパネルはいかがですか?"
|
||||
},
|
||||
"essentials": {
|
||||
"batchImage": "バッチ画像処理",
|
||||
"canny": "Canny",
|
||||
|
||||
@@ -335,6 +335,7 @@
|
||||
},
|
||||
"breadcrumbsMenu": {
|
||||
"app": "앱",
|
||||
"blueprint": "블루프린트",
|
||||
"clearWorkflow": "워크플로 내용 지우기",
|
||||
"deleteBlueprint": "블루프린트 삭제",
|
||||
"deleteWorkflow": "워크플로 삭제",
|
||||
@@ -918,6 +919,10 @@
|
||||
"showSwapNodes": "교체 가능한 노드 표시",
|
||||
"swapNodes": "일부 노드는 대체 가능한 노드로 교체할 수 있습니다"
|
||||
},
|
||||
"errorPanelSurvey": {
|
||||
"ctaButton": "피드백 남기기",
|
||||
"ctaText": "새로운 오류 패널은 어떠신가요?"
|
||||
},
|
||||
"essentials": {
|
||||
"batchImage": "이미지 일괄 처리",
|
||||
"canny": "Canny",
|
||||
|
||||
@@ -335,6 +335,7 @@
|
||||
},
|
||||
"breadcrumbsMenu": {
|
||||
"app": "App",
|
||||
"blueprint": "Blueprint",
|
||||
"clearWorkflow": "Limpar Fluxo de Trabalho",
|
||||
"deleteBlueprint": "Excluir Blueprint",
|
||||
"deleteWorkflow": "Excluir Fluxo de Trabalho",
|
||||
@@ -918,6 +919,10 @@
|
||||
"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,6 +335,7 @@
|
||||
},
|
||||
"breadcrumbsMenu": {
|
||||
"app": "Приложение",
|
||||
"blueprint": "Чертёж",
|
||||
"clearWorkflow": "Очистить рабочий процесс",
|
||||
"deleteBlueprint": "Удалить схему",
|
||||
"deleteWorkflow": "Удалить рабочий процесс",
|
||||
@@ -918,6 +919,10 @@
|
||||
"showSwapNodes": "Показать заменяемые узлы",
|
||||
"swapNodes": "Некоторые узлы можно заменить альтернативами"
|
||||
},
|
||||
"errorPanelSurvey": {
|
||||
"ctaButton": "Оставить отзыв",
|
||||
"ctaText": "Как вам новая панель ошибок?"
|
||||
},
|
||||
"essentials": {
|
||||
"batchImage": "Пакетная обработка изображений",
|
||||
"canny": "Canny",
|
||||
|
||||
@@ -335,6 +335,7 @@
|
||||
},
|
||||
"breadcrumbsMenu": {
|
||||
"app": "Uygulama",
|
||||
"blueprint": "Plan",
|
||||
"clearWorkflow": "İş Akışını Temizle",
|
||||
"deleteBlueprint": "Taslağı Sil",
|
||||
"deleteWorkflow": "İş Akışını Sil",
|
||||
@@ -918,6 +919,10 @@
|
||||
"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,6 +335,7 @@
|
||||
},
|
||||
"breadcrumbsMenu": {
|
||||
"app": "應用程式",
|
||||
"blueprint": "藍圖",
|
||||
"clearWorkflow": "清除工作流程",
|
||||
"deleteBlueprint": "刪除藍圖",
|
||||
"deleteWorkflow": "刪除工作流程",
|
||||
@@ -918,6 +919,10 @@
|
||||
"showSwapNodes": "顯示可替換的節點",
|
||||
"swapNodes": "有些節點可以用其他選項替換"
|
||||
},
|
||||
"errorPanelSurvey": {
|
||||
"ctaButton": "提供回饋",
|
||||
"ctaText": "新的錯誤面板感覺如何?"
|
||||
},
|
||||
"essentials": {
|
||||
"batchImage": "批次圖片",
|
||||
"canny": "Canny 邊緣",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user