From b0fa179b878d63c525b9563fe46c05ffcf1d2e6a Mon Sep 17 00:00:00 2001 From: Terry Jia Date: Sat, 25 Apr 2026 20:03:01 -0400 Subject: [PATCH 001/269] refactor: extract Load3d render loop to load3dRenderLoop (#11623) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Pull the `requestAnimationFrame` loop and its activity-gated tick body out of `Load3d` into a small `startRenderLoop({ tick, isActive })` helper. Pure mechanical refactor — no behavior change. First of four small PRs splitting up the https://github.com/Comfy-Org/ComfyUI_frontend/pull/11495. ## Changes - **What**: New `load3dRenderLoop.ts` exports `startRenderLoop` (returns a `{ stop }` handle). `Load3d.startAnimation()` now constructs a loop through it; `Load3d.remove()` calls `stop()` instead of `cancelAnimationFrame`. Field `animationFrameId: number | null` becomes `renderLoop: RenderLoopHandle | null`. ## Review Focus - The tick body inside `startAnimation()` is byte-identical to the previous inline body — only the rAF scheduling has moved. - `isActive()` is now invoked through a `() => this.isActive()` closure instead of a direct call inside the inline `animate` function, so the activity check still fires once per frame and reads the same fields. - The new helper has 4 unit tests covering: ticks while active, skip while inactive, stop halts ticks, stop is idempotent. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-11623-refactor-extract-Load3d-render-loop-to-load3dRenderLoop-34d6d73d3650815c9c4ec7713e912e37) by [Unito](https://www.unito.io) --- src/extensions/core/load3d/Load3d.test.ts | 86 +++++++++++++++++++ src/extensions/core/load3d/Load3d.ts | 42 ++++----- .../core/load3d/load3dRenderLoop.test.ts | 62 +++++++++++++ .../core/load3d/load3dRenderLoop.ts | 32 +++++++ 4 files changed, 199 insertions(+), 23 deletions(-) create mode 100644 src/extensions/core/load3d/load3dRenderLoop.test.ts create mode 100644 src/extensions/core/load3d/load3dRenderLoop.ts diff --git a/src/extensions/core/load3d/Load3d.test.ts b/src/extensions/core/load3d/Load3d.test.ts index becd29a9aa..00b6e45b41 100644 --- a/src/extensions/core/load3d/Load3d.test.ts +++ b/src/extensions/core/load3d/Load3d.test.ts @@ -383,6 +383,92 @@ describe('Load3d', () => { }) }) + 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('captureScene', () => { it('hides the gizmo helper during capture and restores it after success', async () => { const captureResult = { scene: 'a', mask: 'b', normal: 'c' } diff --git a/src/extensions/core/load3d/Load3d.ts b/src/extensions/core/load3d/Load3d.ts index 4450da2528..98fc83d84f 100644 --- a/src/extensions/core/load3d/Load3d.ts +++ b/src/extensions/core/load3d/Load3d.ts @@ -24,6 +24,8 @@ import type { MaterialMode, UpDirection } from './interfaces' +import type { RenderLoopHandle } from './load3dRenderLoop' +import { startRenderLoop } from './load3dRenderLoop' import { computeLetterboxedViewport, isLoad3dActive } from './load3dViewport' function positionThumbnailCamera( @@ -48,7 +50,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 | null = null private onContextMenuCallback?: (event: MouseEvent) => void private getDimensionsCallback?: () => { width: number; height: number } | null @@ -410,28 +412,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 { @@ -931,9 +928,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() diff --git a/src/extensions/core/load3d/load3dRenderLoop.test.ts b/src/extensions/core/load3d/load3dRenderLoop.test.ts new file mode 100644 index 0000000000..989b372d45 --- /dev/null +++ b/src/extensions/core/load3d/load3dRenderLoop.test.ts @@ -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() + }) +}) diff --git a/src/extensions/core/load3d/load3dRenderLoop.ts b/src/extensions/core/load3d/load3dRenderLoop.ts new file mode 100644 index 0000000000..12d5d42cc5 --- /dev/null +++ b/src/extensions/core/load3d/load3dRenderLoop.ts @@ -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 + } + } + } +} From 996e362ba6ec7c4f584792dabd119ffa4dbd5ea5 Mon Sep 17 00:00:00 2001 From: Dante Date: Sun, 26 Apr 2026 09:36:25 +0900 Subject: [PATCH 002/269] test: add unit tests for form-dropdown internals (#11441) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds 32 unit tests across 3 files covering the internals of the FormDropdown family (input, filter, menu item). Part of a widget-test-coverage sequence. ## Changes - **What**: - `FormDropdownInput.test.ts` (14) — placeholder vs selected-items display, label preference, multi-item join, select-click emit, file-input rendering/accept/multiple/disabled/upload event. - `FormDropdownMenuFilter.test.ts` (8) — option rendering, v-model update on click, single-option disabled state, import-button gating by \`useModelUpload.isUploadButtonEnabled\` (mocked), \`showUploadDialog\` invocation. - `FormDropdownMenuItem.test.ts` (10) — label vs name preference, img/video rendering by injected \`AssetKindKey\`, placeholder gradient, list-small layout, click emits index, mediaLoad event, selection indicator. ## Review Focus - \`useModelUpload\` mocked at the module boundary with a dynamic import of \`vue\` inside \`vi.mock\` (needed because \`vi.hoisted\` runs before imports). - \`AssetKindKey\` provided via \`global.provide\` using the \`ComputedRef\` shape. - \`v-tooltip\` registered as a no-op directive to avoid render errors in happy-dom. - No changes to any source component. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-11441-test-add-unit-tests-for-form-dropdown-internals-3486d73d3650813cb4a1c6568280ef1a) by [Unito](https://www.unito.io) --------- Co-authored-by: GitHub Action --- .../form/dropdown/FormDropdownInput.test.ts | 135 +++++++++++++++++ .../form/dropdown/FormDropdownInput.vue | 23 +-- .../dropdown/FormDropdownMenuFilter.test.ts | 137 ++++++++++++++++++ .../dropdown/FormDropdownMenuItem.test.ts | 125 ++++++++++++++++ .../form/dropdown/FormDropdownMenuItem.vue | 18 +-- .../widgets/components/form/dropdown/types.ts | 22 +++ 6 files changed, 434 insertions(+), 26 deletions(-) create mode 100644 src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownInput.test.ts create mode 100644 src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownMenuFilter.test.ts create mode 100644 src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownMenuItem.test.ts diff --git a/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownInput.test.ts b/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownInput.test.ts new file mode 100644 index 0000000000..05bb63a2b1 --- /dev/null +++ b/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownInput.test.ts @@ -0,0 +1,135 @@ +import { render, screen } from '@testing-library/vue' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import { createI18n } from 'vue-i18n' + +import FormDropdownInput from './FormDropdownInput.vue' +import type { FormDropdownInputProps, FormDropdownItem } from './types' + +const items: FormDropdownItem[] = [ + { id: 'a', name: 'alpha' }, + { id: 'b', name: 'beta', label: 'Beta Label' }, + { id: 'c', name: 'gamma' } +] + +const uploadLabel = 'Upload' + +const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { en: { g: { upload: uploadLabel } } } +}) + +function renderInput( + props: Partial = {}, + listeners: Record void> = {} +) { + return render(FormDropdownInput, { + global: { + plugins: [i18n] + }, + props: { + items, + selected: new Set(), + maxSelectable: 1, + uploadable: false, + disabled: false, + ...props + }, + attrs: listeners + }) +} + +describe('FormDropdownInput', () => { + describe('Display text', () => { + it('shows placeholder when no items are selected', () => { + renderInput({ placeholder: 'Pick one' }) + expect(screen.getByText('Pick one')).toBeInTheDocument() + }) + + it('shows default placeholder when none is provided', () => { + renderInput() + expect(screen.getByText('Select...')).toBeInTheDocument() + }) + + it('shows a single selected item name', () => { + renderInput({ selected: new Set(['a']) }) + expect(screen.getByText('alpha')).toBeInTheDocument() + }) + + it('prefers label over name when selected item has a label', () => { + renderInput({ selected: new Set(['b']) }) + expect(screen.getByText('Beta Label')).toBeInTheDocument() + }) + + it('joins multiple selected item labels with ", "', () => { + renderInput({ selected: new Set(['a', 'c']), maxSelectable: 2 }) + expect(screen.getByText('alpha, gamma')).toBeInTheDocument() + }) + + it('reads display items from displayItems prop when provided', () => { + const displayItems: FormDropdownItem[] = [ + { id: 'a', name: 'ALPHA_DISPLAY' } + ] + renderInput({ selected: new Set(['a']), displayItems }) + expect(screen.getByText('ALPHA_DISPLAY')).toBeInTheDocument() + }) + }) + + describe('Select button', () => { + it('emits select-click when the button is clicked', async () => { + const onSelectClick = vi.fn() + renderInput({}, { onSelectClick }) + const user = userEvent.setup() + await user.click(screen.getByRole('button')) + expect(onSelectClick).toHaveBeenCalledTimes(1) + }) + }) + + describe('Upload affordance', () => { + it('does not render file input when uploadable is false', () => { + renderInput({ uploadable: false }) + expect(screen.queryByLabelText(uploadLabel)).toBeNull() + }) + + it('renders a file input when uploadable is true', () => { + renderInput({ uploadable: true }) + expect(screen.getByLabelText(uploadLabel)).toBeInTheDocument() + }) + + it('passes accept attribute to the file input', () => { + renderInput({ uploadable: true, accept: 'image/png' }) + expect(screen.getByLabelText(uploadLabel)).toHaveAttribute( + 'accept', + 'image/png' + ) + }) + + it('marks file input multiple when maxSelectable > 1', () => { + renderInput({ uploadable: true, maxSelectable: 4 }) + expect(screen.getByLabelText(uploadLabel)).toHaveAttribute('multiple') + }) + + it('does not mark file input multiple when maxSelectable is 1', () => { + renderInput({ uploadable: true, maxSelectable: 1 }) + expect(screen.getByLabelText(uploadLabel)).not.toHaveAttribute('multiple') + }) + + it('disables file input when disabled prop is true', () => { + renderInput({ uploadable: true, disabled: true }) + expect(screen.getByLabelText(uploadLabel)).toBeDisabled() + }) + + it('emits file-change when a file is uploaded', async () => { + const onFileChange = vi.fn() + renderInput({ uploadable: true }, { onFileChange }) + const fileInput = screen.getByLabelText(uploadLabel) as HTMLInputElement + const user = userEvent.setup() + await user.upload( + fileInput, + new File(['hi'], 'hi.txt', { type: 'text/plain' }) + ) + expect(onFileChange).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownInput.vue b/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownInput.vue index 659948d3c1..2079d2d8d2 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownInput.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownInput.vue @@ -1,23 +1,11 @@ diff --git a/src/types/litegraph-augmentation.d.ts b/src/types/litegraph-augmentation.d.ts index 8ee82a1d42..7d3d6e2420 100644 --- a/src/types/litegraph-augmentation.d.ts +++ b/src/types/litegraph-augmentation.d.ts @@ -144,9 +144,13 @@ declare module '@/lib/litegraph/src/litegraph' { * Callback invoked when the node is dropped from an external source, i.e. * a file or another HTML element. * @param e The drag event + * @param claimEvent If true, the handler should call preventDefault and + * stopPropagation synchronously before any await once it has decided to + * accept the drop, so bubbling fallback handlers know not to also process + * the event. * @returns {boolean} True if the drag event should be handled by this node, false otherwise */ - onDragDrop?(e: DragEvent): Promise | boolean + onDragDrop?(e: DragEvent, claimEvent?: boolean): Promise | boolean index?: number runningInternalNodeId?: NodeId From 13b660a15b38d53eca8add3fc337b88ffecfda63 Mon Sep 17 00:00:00 2001 From: Comfy Org PR Bot Date: Sun, 26 Apr 2026 14:36:11 +0900 Subject: [PATCH 004/269] 1.44.10 (#11620) Patch version increment to 1.44.10 **Base branch:** `main` --------- Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com> Co-authored-by: github-actions Co-authored-by: Alexander Brown --- package.json | 2 +- src/locales/ar/main.json | 5 +++++ src/locales/en/nodeDefs.json | 4 ++-- src/locales/es/main.json | 5 +++++ src/locales/fa/main.json | 5 +++++ src/locales/fr/main.json | 5 +++++ src/locales/ja/main.json | 5 +++++ src/locales/ko/main.json | 5 +++++ src/locales/pt-BR/main.json | 5 +++++ src/locales/ru/main.json | 5 +++++ src/locales/tr/main.json | 5 +++++ src/locales/zh-TW/main.json | 5 +++++ src/locales/zh/main.json | 5 +++++ 13 files changed, 58 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 38217cc9f7..ce30cbc9e1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@comfyorg/comfyui-frontend", - "version": "1.44.9", + "version": "1.44.10", "private": true, "description": "Official front-end implementation of ComfyUI", "homepage": "https://comfy.org", diff --git a/src/locales/ar/main.json b/src/locales/ar/main.json index f8b04cbd01..c8d74c71d0 100644 --- a/src/locales/ar/main.json +++ b/src/locales/ar/main.json @@ -335,6 +335,7 @@ }, "breadcrumbsMenu": { "app": "التطبيق", + "blueprint": "المخطط", "clearWorkflow": "مسح سير العمل", "deleteBlueprint": "حذف المخطط", "deleteWorkflow": "حذف سير العمل", @@ -918,6 +919,10 @@ "showSwapNodes": "عرض العقد البديلة", "swapNodes": "يمكن استبدال بعض العقد ببدائل" }, + "errorPanelSurvey": { + "ctaButton": "أعطِ ملاحظاتك", + "ctaText": "ما رأيك في لوحة الأخطاء الجديدة؟" + }, "essentials": { "batchImage": "معالجة صور دفعة واحدة", "canny": "كانّي", diff --git a/src/locales/en/nodeDefs.json b/src/locales/en/nodeDefs.json index f47e9ea8ad..f0bc5d72ad 100644 --- a/src/locales/en/nodeDefs.json +++ b/src/locales/en/nodeDefs.json @@ -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" diff --git a/src/locales/es/main.json b/src/locales/es/main.json index f7fdec9fbc..66226d7faa 100644 --- a/src/locales/es/main.json +++ b/src/locales/es/main.json @@ -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", diff --git a/src/locales/fa/main.json b/src/locales/fa/main.json index d7a64ddd52..a458eacfe0 100644 --- a/src/locales/fa/main.json +++ b/src/locales/fa/main.json @@ -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", diff --git a/src/locales/fr/main.json b/src/locales/fr/main.json index a45d53e612..afac18aae7 100644 --- a/src/locales/fr/main.json +++ b/src/locales/fr/main.json @@ -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", diff --git a/src/locales/ja/main.json b/src/locales/ja/main.json index 3152c9a6ed..823aa5cfe4 100644 --- a/src/locales/ja/main.json +++ b/src/locales/ja/main.json @@ -335,6 +335,7 @@ }, "breadcrumbsMenu": { "app": "アプリ", + "blueprint": "ブループリント", "clearWorkflow": "ワークフローをクリア", "deleteBlueprint": "ブループリントを削除", "deleteWorkflow": "ワークフローを削除", @@ -918,6 +919,10 @@ "showSwapNodes": "代替可能なノードを表示", "swapNodes": "いくつかのノードは代替可能です" }, + "errorPanelSurvey": { + "ctaButton": "フィードバックを送る", + "ctaText": "新しいエラーパネルはいかがですか?" + }, "essentials": { "batchImage": "バッチ画像処理", "canny": "Canny", diff --git a/src/locales/ko/main.json b/src/locales/ko/main.json index b82d0c2124..6b0ace7685 100644 --- a/src/locales/ko/main.json +++ b/src/locales/ko/main.json @@ -335,6 +335,7 @@ }, "breadcrumbsMenu": { "app": "앱", + "blueprint": "블루프린트", "clearWorkflow": "워크플로 내용 지우기", "deleteBlueprint": "블루프린트 삭제", "deleteWorkflow": "워크플로 삭제", @@ -918,6 +919,10 @@ "showSwapNodes": "교체 가능한 노드 표시", "swapNodes": "일부 노드는 대체 가능한 노드로 교체할 수 있습니다" }, + "errorPanelSurvey": { + "ctaButton": "피드백 남기기", + "ctaText": "새로운 오류 패널은 어떠신가요?" + }, "essentials": { "batchImage": "이미지 일괄 처리", "canny": "Canny", diff --git a/src/locales/pt-BR/main.json b/src/locales/pt-BR/main.json index 2b4a9744f7..2cc3dc5436 100644 --- a/src/locales/pt-BR/main.json +++ b/src/locales/pt-BR/main.json @@ -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", diff --git a/src/locales/ru/main.json b/src/locales/ru/main.json index 38b2d48e2e..fba761b33e 100644 --- a/src/locales/ru/main.json +++ b/src/locales/ru/main.json @@ -335,6 +335,7 @@ }, "breadcrumbsMenu": { "app": "Приложение", + "blueprint": "Чертёж", "clearWorkflow": "Очистить рабочий процесс", "deleteBlueprint": "Удалить схему", "deleteWorkflow": "Удалить рабочий процесс", @@ -918,6 +919,10 @@ "showSwapNodes": "Показать заменяемые узлы", "swapNodes": "Некоторые узлы можно заменить альтернативами" }, + "errorPanelSurvey": { + "ctaButton": "Оставить отзыв", + "ctaText": "Как вам новая панель ошибок?" + }, "essentials": { "batchImage": "Пакетная обработка изображений", "canny": "Canny", diff --git a/src/locales/tr/main.json b/src/locales/tr/main.json index 595a3c99b8..750a000dfe 100644 --- a/src/locales/tr/main.json +++ b/src/locales/tr/main.json @@ -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", diff --git a/src/locales/zh-TW/main.json b/src/locales/zh-TW/main.json index e85c309027..c539c913bb 100644 --- a/src/locales/zh-TW/main.json +++ b/src/locales/zh-TW/main.json @@ -335,6 +335,7 @@ }, "breadcrumbsMenu": { "app": "應用程式", + "blueprint": "藍圖", "clearWorkflow": "清除工作流程", "deleteBlueprint": "刪除藍圖", "deleteWorkflow": "刪除工作流程", @@ -918,6 +919,10 @@ "showSwapNodes": "顯示可替換的節點", "swapNodes": "有些節點可以用其他選項替換" }, + "errorPanelSurvey": { + "ctaButton": "提供回饋", + "ctaText": "新的錯誤面板感覺如何?" + }, "essentials": { "batchImage": "批次圖片", "canny": "Canny 邊緣", diff --git a/src/locales/zh/main.json b/src/locales/zh/main.json index 01b9a9a417..870c007f3b 100644 --- a/src/locales/zh/main.json +++ b/src/locales/zh/main.json @@ -335,6 +335,7 @@ }, "breadcrumbsMenu": { "app": "应用", + "blueprint": "蓝图", "clearWorkflow": "清除工作流", "deleteBlueprint": "删除蓝图", "deleteWorkflow": "删除工作流", @@ -918,6 +919,10 @@ "showSwapNodes": "显示可替换节点", "swapNodes": "部分节点可以用替代项替换" }, + "errorPanelSurvey": { + "ctaButton": "提交反馈", + "ctaText": "新的错误面板感觉如何?" + }, "essentials": { "batchImage": "批量图像", "canny": "Canny", From 492bec28c8622b8e95adc54a4e1a9df8567825bb Mon Sep 17 00:00:00 2001 From: Terry Jia Date: Sun, 26 Apr 2026 17:51:44 -0400 Subject: [PATCH 005/269] test: add unit tests for TopBarHeader (#11650) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Add unit tests for `TopBarHeader` mask editor dialog component, raising coverage from 0% to **100%** across statements, branches, functions, and lines. ## Changes - **What**: Add `src/components/maskeditor/dialog/TopBarHeader.test.ts` (17 tests) covering: - Localized title rendering. - Undo / Redo buttons forward to `store.canvasHistory.{undo,redo}`. - Four transform buttons (rotate left / right, mirror horizontal / vertical) call the matching `canvasTransform` action — parametrized via `it.each`. - All four transform error paths: rejected promise is caught, swallowed, and logged with the right `[TopBarHeader] ... failed:` prefix. - Invert calls `canvasTools.invertMask`; Clear calls both `canvasTools.clearMask` and `store.triggerClear`. - Save: hides brush, awaits `saver.save()`, closes the dialog on success; switches button text to "Saving" while in-flight; restores brush + button label and logs on save failure. - Cancel: closes the dialog with the `global-mask-editor` key. ## Review Focus - All five composable / store dependencies are mocked at module level via `vi.hoisted`: `useMaskEditorStore`, `useDialogStore`, `useCanvasTools`, `useCanvasTransform`, `useMaskEditorSaver`. Only the store needs `reactive()` (`brushVisible` flips during save flow); the rest are plain function bags. - `Button.vue` is stubbed to a thin `' + } +})) + +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((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' + }) + }) + }) +}) From 6f6fc88b0f76202e89d6c56d0e8a506a05e76f2d Mon Sep 17 00:00:00 2001 From: Terry Jia Date: Sun, 26 Apr 2026 17:52:29 -0400 Subject: [PATCH 006/269] test: add unit tests for MaskEditorContent main container (#11651) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Add unit tests for `MaskEditorContent` (the mask editor's main orchestration container), raising coverage from 0% to **94.11% / 83.72% / 83.33% / 94.11%** (statements / branches / functions / lines). ## Changes - **What**: Add `src/components/maskeditor/MaskEditorContent.test.ts` (12 tests) covering: - **Mount**: keyboard listeners attached, ResizeObserver observes the container, all 5 canvas refs assigned to the store before init runs. - **Init flow**: `loader.loadFromNode` → `imageLoader.loadImages` → `panZoom.initializeCanvasPanZoom` → `canvasHistory.saveInitialState` → `brushDrawing.initGPUResources` → `initPreviewCanvas` chain runs in order; child UI (`ToolPanel` / `PointerZone` / `SidePanel` / `BrushCursor`) only renders after init succeeds; GPU preview canvas resolution matches the mask canvas. - **Init errors**: rejection from `loader.loadFromNode` or `panZoom.initializeCanvasPanZoom` is caught, logged, and triggers `dialogStore.closeDialog()`. - **ResizeObserver**: callback invokes `panZoom.invalidatePanZoom()` (captured the constructor argument to call it manually). - **Drag**: `Ctrl+drag` is preventDefault'd; plain drag is not. - **Unmount**: cleanup runs `brushDrawing.saveBrushSettings`, `keyboard.removeListeners`, `canvasHistory.clearStates`, `store.resetState`, `dataStore.reset`. ## Review Focus - Heavy mock surface (10 modules): the 3 stores, 5 composables, plus 4 child Vue components and `LoadingOverlay`. All mocks are `vi.hoisted` module-level. `mockStore` is `reactive()` because the source mutates `activeLayer` (visible in template binding), `maskCanvas`, etc.; the rest are plain function bags. - Child components are stubbed to bare `
` so init reveal can be asserted via `screen.findByTestId(...)` without engaging their real implementations (each has its own test file). - `MockResizeObserver` captures the constructor callback in module-level `lastResizeCallback`. The "invalidate on resize" test invokes it manually with empty args — that's enough to exercise the source's `if (panZoom) { await panZoom.invalidatePanZoom() }` branch since the callback only consumes `panZoom` from closure. - happy-dom doesn't propagate `ctrlKey` through the `DragEvent` constructor, so the drag tests set it via `Object.defineProperty(event, 'ctrlKey', { value })` (same pattern used in `PointerZone.test.ts` for wheel `clientX/Y`). - 94.11% line coverage — the two uncovered blocks (`containerRef` missing, canvas refs missing) are early-return error paths unreachable when Vue successfully mounts; not worth constructing a fixture to trigger. - Style aligned with sibling tests: `should ...` naming, `describe` grouped by feature, `vi.hoisted` mocks reset via `beforeEach`, `screen.findByTestId` for async render assertions. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-11651-test-add-unit-tests-for-MaskEditorContent-main-container-34e6d73d365081b38af2e057cb7daf9e) by [Unito](https://www.unito.io) --- .../maskeditor/MaskEditorContent.test.ts | 354 ++++++++++++++++++ .../maskeditor/MaskEditorContent.vue | 1 + 2 files changed, 355 insertions(+) create mode 100644 src/components/maskeditor/MaskEditorContent.test.ts diff --git a/src/components/maskeditor/MaskEditorContent.test.ts b/src/components/maskeditor/MaskEditorContent.test.ts new file mode 100644 index 0000000000..1d6c690c93 --- /dev/null +++ b/src/components/maskeditor/MaskEditorContent.test.ts @@ -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 + +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: `
` + } +})) + +vi.mock('@/components/maskeditor/ToolPanel.vue', () => ({ + default: { + name: 'ToolPanelStub', + props: ['toolManager'], + template: '
' + } +})) + +vi.mock('@/components/maskeditor/PointerZone.vue', () => ({ + default: { + name: 'PointerZoneStub', + props: ['toolManager', 'panZoom'], + template: '
' + } +})) + +vi.mock('@/components/maskeditor/SidePanel.vue', () => ({ + default: { + name: 'SidePanelStub', + props: ['toolManager'], + template: '
' + } +})) + +vi.mock('@/components/maskeditor/BrushCursor.vue', () => ({ + default: { + name: 'BrushCursorStub', + props: ['containerRef'], + template: '
' + } +})) + +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) + }) + }) +}) diff --git a/src/components/maskeditor/MaskEditorContent.vue b/src/components/maskeditor/MaskEditorContent.vue index 5d641bae1c..56268347d5 100644 --- a/src/components/maskeditor/MaskEditorContent.vue +++ b/src/components/maskeditor/MaskEditorContent.vue @@ -1,6 +1,7 @@