Compare commits

...

16 Commits

Author SHA1 Message Date
dante01yoon
c3e2c6266f test: add unit tests for layoutStore hit testing 2026-04-27 14:14:59 +09:00
Dante
c594e30b84 test: harden useKeyboard test setup with vi.hoisted and try/finally (#11659)
## Summary
- Move `mockCanvasHistory` / `mockStore` into `vi.hoisted()` so the mock
state is hoisted before module imports, matching the pattern in
`useCanvasTransform.test.ts`.
- Wrap the temporary `document.activeElement` override in `try/finally`
so the property is restored even if the assertion throws, preventing
state leak into subsequent tests.

- Fixes #11658

## Test plan
- [x] `pnpm test:unit src/composables/maskeditor/useKeyboard.test.ts` —
17/17 pass
- [x] `pnpm typecheck`
- [x] `pnpm lint` (no new warnings)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11659-test-harden-useKeyboard-test-setup-with-vi-hoisted-and-try-finally-34f6d73d36508139be2ddc3095ea6952)
by [Unito](https://www.unito.io)
2026-04-27 03:26:33 +00:00
Dante
2ade779a81 fix: use getAssetFilename in asset browser to avoid showing hashes (#11492)
## Root cause
Three surfaces rendered `asset.name` directly even though in Cloud
production `asset.name` often equals `asset.asset_hash`:

1. **Asset browser modal** (`AssetCard.vue`) used `getAssetDisplayName`,
which — when `user_metadata.name` also held the hash — fell through to
the raw hash.
2. **Load Image node widget dropdown** (`useWidgetSelectItems.ts`)
rendered output items as `${asset.name} [output]`.
3. Queue-mapped output assets (`mapTaskOutputToAssetItem`) populate the
human-readable filename only on `asset.display_name`, not on
`user_metadata.filename` / `metadata.filename`. So surfaces that rely
only on `getAssetFilename` still fall through to `asset.name` (the
hash).

Filename / title resolution is now split into three helpers with
distinct responsibilities:

- `getAssetFilename` (unchanged) — canonical filename for serialization
/ identifier use (workflow widget values, schema validation,
missing-model matching). Never substitutes a display-only string.
- `getAssetDisplayFilename` (new) — filename-first label for surfaces
that render a filename. Adds `asset.display_name` as a fallback before
`asset.name`. Used by the Load Image output dropdown.
- `getAssetCardTitle` (new) — card title / delete-dialog label. Prefers
`user_metadata.name` / `metadata.name` when distinct from `asset.name`
(preserves user-renamed model titles from `ModelInfoPanel`), and falls
through to `getAssetDisplayFilename` for the Cloud hash case.

The dropdown item's `name` field (workflow payload value) is still
`${asset.name} [output]`, so Cloud can continue to resolve the asset by
hash.

Fixes FE-228

Source:
https://comfy-organization.slack.com/archives/C0A4XMHANP3/p1776716352588229

## Red / green verification
| Step | SHA | Purpose |
| --- | --- | --- |
| Red (card — metadata.filename) | `20b32e4f0` | `AssetCard.test.ts`
asserts the rendered title is the human-readable filename when
`asset.name === asset_hash`. |
| Green (card) | `ea889b34c` | `AssetCard.vue` switches from
`getAssetDisplayName` to `getAssetFilename`. |
| Red (widget — metadata.filename) | `318feddec` | Asserts the output
dropdown `label` uses `metadata.filename` when `asset.name` is a hash. |
| Green (widget) | `7b19bde15` | `useWidgetSelectItems.ts` derives
`label` from `getAssetFilename`; `name` (serialized) stays
`\${asset.name} [output]`. |
| Red (widget — display_name path) | `b19716e60` | Failing test for the
queue-mapped shape (`display_name` populated, `user_metadata.filename`
absent). |
| Green (util, reverted) | `533e60d6a` | Initial attempt broadened
`getAssetFilename` to include `display_name`; altered filename semantics
for model-asset consumers. |
| Refactor (scope narrowing) | `7c1085f30` | Reverts the util change;
applies `display_name` fallback locally in the output dropdown only. |
| Red (card — display_name path) | `38a9d4828` | Failing test: AssetCard
must fall back to `display_name` when filename metadata is absent. |
| Green (helper split) | `4ca0f620f` | Introduces
`getAssetDisplayFilename`; swaps AssetCard + widget dropdown to use it.
Adds helper unit tests. |
| Red (card — preserves curated name) | `dc2e9231d` | Failing test: a
user-curated `user_metadata.name` distinct from `asset.name` must win
over the filename; plus non-regression guard that
curated-name-equal-to-hash still falls back to filename. |
| Green (card title helper) | `5decf3a2b` | Adds `getAssetCardTitle`
(curated name when distinct, else `getAssetDisplayFilename`). AssetCard
title + delete-dialog swap to it; widget dropdown stays on
`getAssetDisplayFilename`. |

Side-effect audit (`getAssetFilename` still canonical):
- `createModelNodeFromAsset.ts`, `createAssetWidget.ts`,
`missingModelScan.ts`, `useComboWidget.ts`, `useWidgetSelectItems.ts`
asset-mode `name` — all still resolve through `getAssetFilename`, so
model-asset widget serialization and missing-model matching are
unaffected.

## Screenshots

### As is — asset browser card

<img width="1269" height="899" alt="Screenshot 2026-04-21 at 10 49 41
AM"
src="https://github.com/user-attachments/assets/7cffb585-4e64-4037-8bb1-5dd40215597e"
/>

### To be

<img width="1145" height="533" alt="Screenshot 2026-04-25 at 7 25 17 PM"
src="https://github.com/user-attachments/assets/8f12388a-16df-4892-83b4-c8d1f033f190"
/>


_Verified locally via `pnpm dev:cloud`: asset browser cards preserve
user-curated display names, Cloud-hash cases render the filename, and
the Load Image output dropdown also renders the human-readable filename.
The selected value still serializes the hash path._

## Test plan
- [ ] \`pnpm test:unit -- AssetCard.test\` passes (4 cases:
metadata.filename, display_name fallback, curated-name preservation,
curated-name==hash fallback)
- [ ] \`pnpm test:unit -- assetMetadataUtils.test\` passes (53 cases
incl. new helper coverage)
- [ ] \`pnpm test:unit -- useWidgetSelectItems.test\` passes (26 cases)
- [ ] \`pnpm test:unit -- useMissingModelInteractions.test\` passes
(guards against model-asset regressions)
- [ ] Asset browser modal: user-renamed model shows curated name; Cloud
hash outputs show filename
- [ ] Load Image node → widget dropdown → Outputs tab shows original
filenames
- [ ] Delete-confirm dialog references the same curated name as the card
title
- [ ] Model-asset widgets (Checkpoint / LoRA / etc.) still serialize the
model filename
- [ ] Submitting a workflow that references a Cloud output still
executes

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11492-fix-use-getAssetFilename-in-asset-browser-to-avoid-showing-hashes-3496d73d36508148b8a3fb0482fa668e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-27 03:16:18 +00:00
Dante
25f493bd30 test(assets): add E2E spec for sort options (#11634)
## Summary

Adds 6 @cloud-tagged Playwright tests covering the assets sidebar sort
menu (newest/oldest/longest/fastest). Phase 4 of the assets-test plan.

**Stacked on #11632** — base will retarget to \`main\` once the
foundation PR merges. Independent of #11633 (filter E2E) and can be
reviewed in parallel.

## Changes

- **What**: New file `browser_tests/tests/sidebar/assets-sort.spec.ts`.
Uses the `createJobsWithExecutionTimes()` factory and the
`sortLongestFirst` / `sortFastestFirst` locators added in #11632.
- **Coverage**:
  - Settings menu exposes all four sort options in cloud mode
  - Default order is newest first (descending `create_time`)
  - "Oldest first" reverses the order
  - "Longest first" puts the slowest execution at the top
  - "Fastest first" puts the quickest execution at the top
  - Sort persists across search-input edits
- **Breaking**: none

## Review Focus

- **Misaligned (create_time, duration) axes**: fixture data is
deliberately constructed so newest/oldest and longest/fastest produce
distinct orderings — no test can false-pass by satisfying a different
sort. See the table comment at the top of the spec.
- **`@cloud` tag is required**: sort options are gated behind
`:show-sort-options="isCloud"`, which depends on the compile-time
`__DISTRIBUTION__` flag. Tests run only against the `cloud` Playwright
project.
- **Local verification needed**: maintainer should verify with `pnpm
dev:cloud` + `pnpm test:browser:local --project cloud --grep "sort
options"` before merging — I could not run the cloud dev server
end-to-end in my environment.

Fixes #10779

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11634-test-assets-add-E2E-spec-for-sort-options-34e6d73d365081a79facde5bde2e18c6)
by [Unito](https://www.unito.io)
2026-04-27 12:26:08 +09:00
Comfy Org PR Bot
b8b5e1ec1f 1.44.11 (#11656)
Patch version increment to 1.44.11

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11656-1-44-11-34f6d73d3650812aa813ee9efb11dced)
by [Unito](https://www.unito.io)

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2026-04-27 02:14:50 +00:00
Terry Jia
59ef69f355 test: add unit tests for useCoordinateTransform mask editor composable (#11640)
## Summary

Add unit tests for `useCoordinateTransform` mask editor composable,
raising coverage from 2.43% to 100% (statements / branches / functions /
lines).

## Changes

- **What**: Add
`src/composables/maskeditor/useCoordinateTransform.test.ts` (14 tests)
covering both `screenToCanvas` and `canvasToScreen`: identity (display
matches bitmap), uniform downscale (bitmap larger than display),
`pointerZone`-vs-`canvasContainer` offset, non-uniform per-axis scaling,
screen↔canvas round-trip, and the three "element missing" branches
(`pointerZone` / `canvasContainer` / `maskCanvas` null) that should warn
and return `{x:0,y:0}`.

## Review Focus

- Mocked `createSharedComposable` to a pass-through so each test gets a
fresh transform reading the latest `mockStore` refs (otherwise the
shared instance captures stale element references between tests).
- DOM rects are stubbed via `vi.spyOn(el, 'getBoundingClientRect')`
rather than constructing fake DOMRects, so `unref(...)` in the
composable still receives a real `HTMLElement` / `HTMLCanvasElement`.
- Round-trip test (`screenToCanvas` → `canvasToScreen`) verifies the two
functions are mathematical inverses under the offset + scale
combination, which is the actual invariant the rest of the editor relies
on.
- Style aligned with sibling tests: `should ...` naming, `describe`
grouped by public method, explicit `MockStore` type alias, helper
factories `createElementWithRect` / `createCanvasWithRect`.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11640-test-add-unit-tests-for-useCoordinateTransform-mask-editor-composable-34e6d73d3650814d95bdef66e36328e8)
by [Unito](https://www.unito.io)
2026-04-26 22:02:41 -04:00
Terry Jia
9ad052467d test: add unit tests for useKeyboard mask editor composable (#11639)
## Summary

Add unit tests for `useKeyboard` mask editor composable, raising
coverage from 0% to 100% (statements/lines/functions, 95.65% branch).

## Changes

- **What**: Add `src/composables/maskeditor/useKeyboard.test.ts` (17
tests) covering key tracking (`isKeyDown`), space-key
blur/preventDefault, undo/redo shortcuts (Ctrl/Meta+Z, Ctrl+Shift+Z,
Ctrl+Y), modifier-key edge cases (Alt suppression, no-modifier no-op,
Ctrl+Shift+Y ignored), `window blur` clearing keys, and listener
teardown via `removeListeners`.

## Review Focus

- Mock surface is intentionally minimal — only `useMaskEditorStore` is
mocked because the composable only reaches
`store.canvasHistory.{undo,redo}`.
- `afterEach(keyboard.removeListeners)` is required: the composable
attaches listeners to `document` / `window`, so without teardown earlier
test instances leak handlers and inflate mock call counts in later
tests.
- Tests dispatch real `KeyboardEvent`s via `document.dispatchEvent`
rather than calling the internal handlers directly, so they exercise the
actual `addEventListener` wiring.
- Test style aligned with existing mask editor tests: `should ...`
naming, `describe` grouped by public method, explicit `MockStore` /
`MockCanvasHistory` type aliases.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11639-test-add-unit-tests-for-useKeyboard-mask-editor-composable-34e6d73d36508129b437d0270d9424d8)
by [Unito](https://www.unito.io)
2026-04-26 22:02:19 -04:00
Dante
aa730c8cb5 test(assets): add unit tests and E2E fixture groundwork for sidebar filter & sort (#11632)
## Summary

Lays the test foundation for the two open assets-testing issues — Phase
1 (fixtures + page object) and Phase 2 (unit tests). Phase 3/4 (the
actual E2E specs) follow in stacked PRs.

## Changes

- **What**: 27 new unit tests covering `useMediaAssetFiltering`,
`MediaAssetFilterMenu`, and `MediaAssetSettingsMenu`; reusable
Playwright fixture factories for diverse media kinds and execution-time
specs; new locators + helpers on `AssetsSidebarTab` for the filter menu
and longest/fastest sort options. No production code touched.
- **Breaking**: none

### New files
- `src/platform/assets/composables/useMediaAssetFiltering.test.ts` — 11
cases: single/multi-OR media-type filter, `'3D'` → `'3d'` filename
normalization, exclusion of unsupported kinds, all four sort modes,
`created_at` fallback when `user_metadata.create_time` is absent,
filter+sort composition.
- `src/platform/assets/components/MediaAssetFilterMenu.test.ts` — 6
cases: checkbox rendering, prop-driven `aria-checked`, click toggling
(add/remove/append), keyboard activation (Enter/Space).
- `src/platform/assets/components/MediaAssetSettingsMenu.test.ts` — 10
cases: view-mode v-model, `showSortOptions` and `showGenerationTimeSort`
visibility gates, `v-model:sortBy` round-trip for
newest/oldest/longest/fastest.

### Extended files
- `browser_tests/fixtures/helpers/AssetsHelper.ts` — added
`MediaKindFixture` type, optional `mediaKind` shorthand on
`createMockJob` (sets both filename extension and
`preview_output.mediaType`), plus `createMixedMediaJobs(kinds)` and
`createJobsWithExecutionTimes(specs)` factories for unambiguous
filter/sort assertions.
- `browser_tests/fixtures/components/SidebarTab.ts` — added
`filterButton`, per-type checkbox locators
(`filterImage/Video/Audio/3DCheckbox`), `sortLongestFirst`,
`sortFastestFirst`, plus `openFilterMenu()`, `filterCheckbox(kind)`,
`toggleMediaTypeFilter(kind)`, and `getAssetCardOrder()` helpers.

## Review Focus

- **Naming of `MediaKindFixture` values** — `'images'` is plural to
match existing API conventions emitted by the backend /
`useMediaAssetGalleryStore`; `'video' | 'audio' | '3D'` follow the
singular `MediaKind` type. Open to renaming if a unified shape is
preferred.
- **Filter button locator strategy** — `MediaAssetFilterButton` has no
`aria-label`, so the page object targets it via the
`icon-[lucide--list-filter]` class. Happy to add a `data-testid` or
`aria-label` to the source component if reviewers prefer a more durable
hook (would be a one-line source change in a follow-up).

## Follow-up PRs

- Phase 3 (E2E for media-type filter) → closes #10780
- Phase 4 (E2E for asset sort) → closes #10779

Both are stacked on this branch and can be reviewed/merged in either
order once this lands.

References #10779, #10780.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11632-test-assets-add-unit-tests-and-E2E-fixture-groundwork-for-sidebar-filter-sort-34e6d73d3650815c9900e5fd7cc7eab0)
by [Unito](https://www.unito.io)
2026-04-27 01:46:50 +00:00
Terry Jia
cc1fe65348 test: add unit tests for maskEditorDataStore (#11641)
## Summary

Add unit tests for `maskEditorDataStore` Pinia store, raising coverage
from 0% to 100% (statements / branches / functions / lines).

## Changes

- **What**: Add `src/stores/maskEditorDataStore.test.ts` (13 tests)
covering initial state, the three `computed` predicates
(`hasValidInput`, `hasValidOutput`, `isReady` including the `isLoading`
interaction), the `setLoading(loading, error?)` action across its three
branches (no error arg, truthy error arg, empty-string error arg — empty
string is falsy so it must NOT clobber an existing `loadError`), and
`reset()` clearing every field including derived predicates.

## Review Focus

- Uses `createTestingPinia({ stubActions: false })` so action
implementations actually run, matching the pattern used by other store
tests in `src/stores/` (e.g. `dialogStore.test.ts`).
- The `setLoading('', '')` test guards a real branch in the source — `if
(error)` skips assignment for empty strings, so callers can't
accidentally clear a previous error by passing `''`. Worth keeping if
anyone tightens the guard later.
- `reset()` test asserts both raw refs and the three computed values
flip back to `false` / `null`, so a future regression in either
direction is caught.
- Style aligned with sibling tests: `should ...` naming, `describe`
grouped by exposed property/action, `createImage` / `createCanvas` /
`createOutputData` helpers to keep arrange blocks short.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11641-test-add-unit-tests-for-maskEditorDataStore-34e6d73d36508121b8e5d185178310ab)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-26 21:38:38 -04:00
Terry Jia
0f66f76b87 test: add unit tests for mask editor control components (#11642)
## Summary

Add unit tests for the three mask editor control components
(`DropdownControl`, `SliderControl`, `ToggleControl`), raising each from
partial (20% / 37.5% / 44.4%) to 100% across all coverage dimensions.

## Changes

- **What**: Add three sibling test files under
`src/components/maskeditor/controls/`:
- `DropdownControl.test.ts` (5 tests): label render, normalization of
`string[]` options into `{label,value}`, pass-through of `{label,value}`
options, `modelValue` reflected as the selected option,
`update:modelValue` emitted on change.
- `SliderControl.test.ts` (4 tests): label render,
`min`/`max`/`step`/`modelValue` exposed on `<input type="range">`,
`step` defaults to `1` when omitted, `update:modelValue` emitted as a
`number` (not string) on input.
- `ToggleControl.test.ts` (5 tests): label render, `modelValue`
reflected as `checked`, `update:modelValue` emitted as `true` / `false`
on toggle.

## Review Focus

- Stack matches the project's prevailing Vue test pattern
(`@testing-library/vue` + `@testing-library/user-event`, e.g.
`Badge.test.ts`, `BatchCountEdit.test.ts`).
- `update:modelValue` is asserted via the `'onUpdate:modelValue'`
callback prop rather than `emitted()` — keeps tests focused on
observable behavior and avoids reaching into the wrapper.
- `SliderControl` uses `fireEvent.input` instead of
`userEvent.type/clear`: `<input type="range">` is non-editable for
`userEvent` (`clear() is only supported on editable elements`). The
single eslint-disable for `testing-library/prefer-user-event` is
annotated with the reason.
- `DropdownControl` test guards both branches of the `string` →
`{label,value}` normalization (string array path and pre-normalized
object array path), since that's the only meaningful logic in the file.
- Style aligned with sibling tests: `should ...` naming, per-file
`renderComponent` helper accepting a `props` override and an `onUpdate`
callback parameter.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11642-test-add-unit-tests-for-mask-editor-control-components-34e6d73d3650812aba2ae34a760489e2)
by [Unito](https://www.unito.io)
2026-04-26 21:38:16 -04:00
Terry Jia
bc16865019 test: add unit tests for useToolManager mask editor composable (#11643)
## Summary

Add unit tests for `useToolManager` mask editor composable, raising
coverage from 0% to 100% (statements / functions / lines, 97.53%
branch).

## Changes

- **What**: Add `src/composables/maskeditor/useToolManager.test.ts` (35
tests) covering:
- `switchTool`: store update, layer auto-switch via
`newActiveLayerOnSet`, custom-cursor branch, no-cursor (default
`'none'`) branch, missing-`pointerZone` no-throw guard.
- `setActiveLayer`: rgb-while-mask-only-tool → swap to `PaintPen`,
mask-while-`PaintPen` → swap to `MaskPen`, no-swap path.
- `updateCursor`: same custom-cursor / default-cursor split plus
`brushPreviewGradientVisible = false` post-condition.
- `currentTool` watcher: clears `lastColorSelectPoint` only when leaving
`MaskColorFill`.
- `handlePointerDown`: touch-ignore, pen pointer registration,
middle-button pan, space+left pan, `MaskPen`/`PaintPen` left-button
drawing, `PaintPen` continue-drawing branch (`button !== 0 && buttons
=== 1`), `MaskBucket` flood fill (with coord transform),
`MaskColorFill`, alt+right brush adjustment, right-click drawing for
drawing tools, no-op for non-drawing tools.
- `handlePointerMove`: touch-ignore, cursor position update,
middle-button pan, space+left pan, non-drawing-tool ignore, alt+right
brush adjustment while `isAdjustingBrush`, left/right drag drawing.
- `handlePointerUp`: state cleanup (`isPanning` / `brushVisible` /
`isAdjustingBrush`), pen pointer removal, touch-pointer early bail
before `drawEnd`.

## Review Focus

- Mock store is wrapped in `reactive()` so the `watch(() =>
store.currentTool, ...)` actually fires when tests mutate `currentTool`.
Plain object mocks would silently no-op the watcher branch.
- Each `setup()` runs `useToolManager` inside its own `effectScope`,
stopped in `afterEach`. Without scoping, watchers from previous tests
stay attached to the shared reactive store and accumulate (a single
mutation in test N would call `clearLastColorSelectPoint` N times).
- Mocked `app.extensionManager.setting.get` because `useBrushDrawing`
factory reads two settings synchronously at construction time. The mock
returns deterministic defaults so we don't need `useSettingStore`
plumbing.
- Pointer-event factory builds the minimal shape (`button` / `buttons` /
`pointerType` / `offset*` / `client*` / `altKey` / `pointerId`) — no
jsdom `PointerEvent` constructor noise. `preventDefault` is a `vi.fn()`
because the source calls it unconditionally.
- Style aligned with sibling tests: `should ...` naming, `describe`
grouped by exposed function/watcher, typed `MockStore`, helper
`pointerEvent({ ... })` and `setup()`.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11643-test-add-unit-tests-for-useToolManager-mask-editor-composable-34e6d73d36508184b017ebd04626b29d)
by [Unito](https://www.unito.io)
2026-04-26 20:49:01 -04:00
Terry Jia
206a367379 test: add unit tests for useMaskEditor composable (#11644)
## Summary

Add unit tests for `useMaskEditor` composable, raising coverage from 0%
to 100% (statements / branches / functions / lines).

## Changes

- **What**: Add `src/composables/maskeditor/useMaskEditor.test.ts` (7
tests) covering `openMaskEditor`:
- Happy path: dialog opened once, `node` forwarded as a prop, header /
content components attached.
- Modal dialog config (`modal` / `maximizable` / `closable` flags)
forwarded to PrimeVue dialog props.
- Acceptance path for nodes with no `imgs` but `previewMediaType ===
'image'`.
- Three guard paths that should log and bail: `node` is null, node with
empty `imgs` and no image preview, node with empty `imgs` and a
non-image preview type (e.g. `'video'`).

## Review Focus

- Mocked `useDialogStore` with a single shared `showDialog` spy — the
only contract under test is "we forwarded these props to the store
action", so instantiating Pinia would just add noise.
- `TopBarHeader.vue` and `MaskEditorContent.vue` are stubbed because
they pull in the full mask-editor render tree; we only assert they're
forwarded as `headerComponent` / `component`, not what they render.
- `console.error` is spied per-test so the bail messages are observable
but don't pollute runner output.
- `nodeWithImage` factory uses a structural `NodeShape` (`{ imgs?,
previewMediaType? }`) rather than `Partial<LGraphNode>` because the real
`LGraphNode` type requires a `LGraphNodeConstructor`-shaped
`constructor` field, which would force every test to construct a full
graph node — irrelevant to the contract being tested.
- Style aligned with sibling tests: `should ...` naming, `describe`
grouped by exposed function (`openMaskEditor`), helper factory.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11644-test-add-unit-tests-for-useMaskEditor-composable-34e6d73d365081e98336db0a92c37ccf)
by [Unito](https://www.unito.io)
2026-04-26 20:48:28 -04:00
Terry Jia
7e8ede376b test: add unit tests for maskEditorStore (#11645)
## Summary

Add unit tests for `maskEditorStore` Pinia store, raising coverage from
0% to 100% (statements / branches / functions / lines).

## Changes

- **What**: Add `src/stores/maskEditorStore.test.ts` (30 tests)
covering:
- Brush setters: `setBrushSize` (1–250), `setBrushOpacity` (0–1),
`setBrushHardness` (0–1), `setBrushStepSize` (1–100) — each tested at
lower bound, upper bound, and in-range.
  - `resetBrushToDefault`: documents the exact default brush shape.
- Other clamped setters: `setPaintBucketTolerance` /
`setColorSelectTolerance` / `setMaskTolerance` (0–255), `setFillOpacity`
/ `setSelectionOpacity` (0–100), `setMaskOpacity` (0–1), `setZoomRatio`
(0.1–10).
- `setPanOffset` / `setCursorPoint`: copy-by-value semantics — mutating
the input after the call must not leak into store state.
  - `resetZoom` / `triggerClear`: monotonic counter bumps.
- `maskColor` computed: `Black`, `White`, `Negative` blend modes plus
the `default:` fallback for unknown values.
  - `canUndo` / `canRedo` proxy through to mocked `useCanvasHistory`.
- Canvas → ctx watchers: setting `maskCanvas` / `rgbCanvas` /
`imgCanvas` derives the corresponding `*Ctx` via `getContext('2d', {
willReadFrequently: true })`. Clearing the canvas leaves the previous
ctx in place (parametrized via `it.each` for all three).
- `resetState`: restores all non-DOM state to documented defaults;
explicitly verifies DOM refs (`maskCanvas` / `pointerZone` / `image`)
are NOT cleared so the editor can reuse mounted elements after a reset.

## Review Focus

- `useCanvasHistory` is mocked via `vi.hoisted` so each test gets the
same exposed `canUndo` / `canRedo` refs while the store's internal
`canvasHistory` reference is untouched. Without this, the store would
call into the real history with `null` canvas refs.
- `setPanOffset` / `setCursorPoint` tests mutate the input *after* the
call — that's the actual behavioral contract (defensive copy via
spread), not a default-value check.
- `resetState` test sets *every* field to a non-default before calling,
so the test fails if `resetState` ever forgets to reset a field. Final
assertions are positive (matches default), not weak negative checks.
- The "DOM refs preserved on reset" assertion is the
surprising-on-purpose part: a future refactor that adds
`maskCanvas.value = null` to `resetState` would break the editor's
ability to reuse mounted canvases after clearing internal state.
- `it.each` for the three canvas/ctx pairs covers the watcher's null
branch without three near-duplicate tests.
- `makeCanvas` overrides `canvas.getContext` directly rather than using
`vi.spyOn` because `HTMLCanvasElement.getContext` has overloads (2d /
webgl / webgpu / bitmaprenderer) and TypeScript picks the GPU overload
by default for spy return type inference.
- Style aligned with sibling `maskEditorDataStore.test.ts`:
`createTestingPinia({ stubActions: false })`, `should ...` naming,
`describe` grouped by exposed property/action, no default-only
change-detector tests.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11645-test-add-unit-tests-for-maskEditorStore-34e6d73d3650818e9855cd9f9f13e62a)
by [Unito](https://www.unito.io)
2026-04-26 20:47:58 -04:00
Terry Jia
7bfbd0d7f3 test: add unit tests for mask editor settings panels (#11647)
## Summary

Add unit tests for the five mask editor settings panel components,
raising each from 0–35% to ~98–100% across coverage dimensions.

## Changes

- **What**: Add 5 sibling test files under `src/components/maskeditor/`
(47 tests total):
- `PaintBucketSettingsPanel.test.ts` (4): both sliders bind to / write
through to `setPaintBucketTolerance` / `setFillOpacity`. **100%**.
- `ColorSelectSettingsPanel.test.ts` (8): all 7 controls (4 sliders, 2
toggles, 1 dropdown) bind to and write through to the right store
fields/setters; method dropdown casts to `ColorComparisonMethod`.
**100%**.
- `BrushSettingsPanel.test.ts` (13): shape buttons (Arc/Rect), reset to
default, four numeric inputs (size/opacity/hardness/step), logarithmic
size slider including the cached-raw-value path
(`Math.round(Math.pow(250, x))`), color input v-model, color input ref
forwarded to / cleared from store on mount/unmount.
- `ImageLayerSettingsPanel.test.ts` (17): mask opacity slider + canvas
style sync, blend-mode select with all 3 enum values + default fallback,
three layer-visibility checkboxes (with and without canvas refs),
activate-layer button forwarding to `toolManager.setActiveLayer`,
disabled-when-active and "show paint button only when Eraser" branches,
base image preview src binding.
- `SettingsPanelContainer.test.ts` (5): tool → component routing
(`MaskBucket` → bucket panel, `MaskColorFill` → color panel, anything
else → brush panel).

## Review Focus

- Stack matches sibling control tests (`@testing-library/vue` +
`userEvent` + stub child controls). Each panel's child `SliderControl` /
`ToggleControl` / `DropdownControl` is replaced by a minimal `<button>`
stub that emits `update:modelValue` on click, so panel logic is
decoupled from control internals (already covered by their own tests).
- Two panels (`Brush`, `ImageLayer`) need real reactivity
(`brushSettings.size` changes through a setter must propagate to the
slider's modelValue computed; `currentTool` / `activeLayer` mutations
between tests). They use `reactive()` from Vue at top level + a `let
mockStore` reset in `beforeEach`. Plain object mocks would either
silently no-op or leak state between tests.
- The two reactive panels enable file-level `eslint-disable
testing-library/no-container, testing-library/no-node-access` because
the unlabeled DOM (shape divs, unlabeled `<input type="number">`/`<input
type="color">`/`<input type="checkbox">`/`<select>`) genuinely can't be
queried via `screen.getByRole/Label`. Each disable has a comment
explaining why.
- `BrushSettingsPanel` has a non-trivial cached-value branch in
`brushSizeSliderValue` computed: when `rawSliderValue` and `brushSize`
are in sync, the getter returns the raw float instead of recomputing
`log(size)/log(250)`. Test stubs `setBrushSize` to actually write back
so the next read takes the cached path.
- `ImageLayerSettingsPanel` has a `display: 'block' | 'none'` style
binding. happy-dom doesn't populate `el.style.display` from inline style
strings reliably, so the test asserts via `el.getAttribute('style')`
instead.
- `SettingsPanelContainer` uses inline component stubs that render
unique text — assertion is just `textContent.toContain('brush-panel')`
etc. No need for component-instance probing.
- Style aligned with sibling tests: `should ...` naming, `describe`
grouped by feature (button group, slider, etc.), `vi.hoisted` for mock
state where reactivity isn't required.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11647-test-add-unit-tests-for-mask-editor-settings-panels-34e6d73d36508117911bc0850ce085e1)
by [Unito](https://www.unito.io)
2026-04-26 20:27:26 -04:00
Terry Jia
32b266f3e9 test: add unit tests for SidePanel, MaskEditorButton, and BrushCursor (#11648)
## Summary

Add unit tests for three small mask editor Vue components (`SidePanel`,
`MaskEditorButton`, `BrushCursor`), all reaching **100%** across
coverage dimensions.

## Changes

- **What**: Add 3 sibling test files (17 tests total):
- `SidePanel.test.ts` (3): renders both `SettingsPanelContainer` and
`ImageLayerSettingsPanel`; forwards `toolManager` prop through to
`ImageLayerSettingsPanel`; works with the prop omitted.
- `MaskEditorButton.test.ts` (3): renders with localized `aria-label`
when `isSingleImageNode` is true; hidden via `v-show` (style `display:
none`) when false; click executes `Comfy.MaskEditor.OpenMaskEditor`
command.
- `BrushCursor.test.ts` (11): opacity 1 / 0 from `brushVisible`; size =
2 × effective brush size × zoom (with hardness scaling); border-radius
50% / 0% for Arc / Rect; position from `cursorPoint + panOffset -
radius`, with optional `containerRef.getBoundingClientRect` offset;
gradient preview hidden / shown; flat fill at `effectiveHardness === 1`.

## Review Focus

- `SidePanel` test stubs both child panels to bare `<div data-testid>`
so the test verifies wiring (prop forwarding, child rendering) without
being affected by the children's i18n / store dependencies.
- `MaskEditorButton` mocks `useCommandStore.execute` and
`useSelectionState.isSingleImageNode` (as a Vue ref). Real i18n via
`createI18n` + `globalInjection: true` — the template uses `$t(...)`
which requires global injection in composition mode.
- For the v-show hidden case, `getByLabelText('...', { selector:
'button' })` works where `getByRole('button', { hidden: true })` doesn't
reliably resolve through the `<Button>` wrapper component.
- `BrushCursor` uses `reactive()` mock store and queries the brush /
gradient elements by id (`#maskEditor_brush`,
`#maskEditor_brushPreviewGradient`). File-level eslint-disable for
`testing-library/no-node-access` because the component's anchor elements
are styled divs without ARIA roles or labels.
- The `radial-gradient` (hardness < 1) branch of `gradientBackground` is
intentionally **not** asserted via rendered DOM: the computed returns a
multi-line template literal that happy-dom's CSS parser drops entirely.
The math (`getEffectiveBrushSize` / `getEffectiveHardness`) is covered
by `brushUtils.test.ts`. v8 reports 100% branch coverage because Vue
evaluates the computed regardless of whether the resulting style string
is parsed by the DOM.
- Style aligned with sibling tests: `should ...` naming, `describe`
grouped by feature, `vi.hoisted` for command-store / selection-state
mocks, real i18n via `createI18n`.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11648-test-add-unit-tests-for-SidePanel-MaskEditorButton-and-BrushCursor-34e6d73d365081e38c4afee32ddf2b0b)
by [Unito](https://www.unito.io)
2026-04-26 20:08:03 -04:00
Terry Jia
2d4ca9c387 test: add unit tests for PointerZone and ToolPanel (#11649)
## Summary

Add unit tests for `PointerZone` and `ToolPanel` mask editor components,
raising each from 0% to **91.89% / 100%** respectively.

## Changes

- **What**: Add 2 sibling test files (24 tests total):
- `PointerZone.test.ts` (13): mount exposes root element to
`store.pointerZone`; pointer events (down/move/up) forward to
`toolManager.handlePointer*`; `pointerleave` hides brush + clears
cursor; `pointerenter` calls `toolManager.updateCursor`; touch events
(start/move/end) forward to `panZoom.handleTouch*`; wheel zooms then
updates cursor with wheel coords; `isPanning` watcher sets cursor to
"grabbing" then back via `updateCursor`; contextmenu's default is
prevented.
- `ToolPanel.test.ts` (11): one container rendered per `allTools`; icon
HTML for each tool; current tool highlighted with
`maskEditor_toolPanelContainerSelected` class while others are not;
clicking a container calls `toolManager.switchTool` with the matching
enum value; zoom indicator rounds `displayZoomRatio * 100`; dimensions
text reflects `image.width × image.height` (or empty when no image);
clicking the zoom indicator calls `store.resetZoom`.

## Review Focus

- `PointerZone` mocks `useToolManager` / `usePanAndZoom` to plain
function bags via factory helpers. The component is mostly forwarding,
so the test's value is in *event mapping* (which event triggers which
handler), not handler internals.
- happy-dom doesn't propagate `clientX` / `clientY` through the
`WheelEvent` constructor; the wheel test sets them via
`Object.defineProperty` after construction. Without this,
`updateCursorPosition` reads `undefined`.
- `PointerZone` ends at 91.89% statements / 70% branch — uncovered lines
are the `onMounted` early-return when `pointerZoneRef.value` is
`undefined` (always set in tests) and the watcher's same guard. Both
unreachable in normal use, intentionally not covered.
- `ToolPanel` mocks `iconsHtml` to `<svg data-testid="icon-${tool}" />`
markers, letting tests assert per-tool rendering via `getByTestId`. Real
i18n via `createI18n` for the reset-zoom tooltip text.
- `getToolContainers` queries by class because tool buttons are
unlabeled `<div>`s with click handlers (no role / aria); a file-level
`eslint-disable testing-library/no-node-access` covers this and the
dimensions-span placeholder query.
- Style aligned with sibling tests: `should ...` naming, `describe`
grouped by feature, `reactive()` mock store + `let mockStore` reset in
`beforeEach`, real i18n where keys are user-visible.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11649-test-add-unit-tests-for-PointerZone-and-ToolPanel-34e6d73d3650817daf85c0d33d16899d)
by [Unito](https://www.unito.io)
2026-04-26 20:07:35 -04:00
37 changed files with 4790 additions and 17 deletions

View File

@@ -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
@@ -263,6 +283,12 @@ export class AssetsSidebarTab extends SidebarTab {
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
public readonly gridViewOption: Locator
@@ -270,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
@@ -301,10 +329,16 @@ 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]'))
@@ -336,8 +370,10 @@ export class AssetsSidebarTab extends SidebarTab {
return this.page.getByText(title)
}
filterCheckbox(label: string) {
return this.page.getByRole('checkbox', { name: label })
filterCheckbox(filter: MediaFilterKind | MediaFilterLabel) {
return this.page.getByRole('checkbox', {
name: getMediaFilterLabel(filter)
})
}
getAssetCardByName(name: string) {
@@ -392,13 +428,26 @@ export class AssetsSidebarTab extends SidebarTab {
async openFilterMenu() {
await this.dismissToasts()
await this.filterButton.click()
// Wait for popover content with checkboxes to render
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' })

View File

@@ -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']

View 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()
})
})

View 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'])
})
})

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.44.10",
"version": "1.44.11",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",

View File

@@ -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')
})
})

View 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.
})
})

View File

@@ -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

View 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()
})
})
})

View 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')
})
})

View 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('')
})
})
})

View 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)
})
})

View 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)
})
})
})

View File

@@ -1,6 +1,7 @@
<template>
<div
ref="pointerZoneRef"
data-testid="pointer-zone"
class="h-full w-[calc(100%-4rem-220px)]"
@pointerdown="handlePointerDown"
@pointermove="handlePointerMove"

View 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')
})
})

View 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')
})
})

View 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)
})
})
})

View File

@@ -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>

View 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')
})
})

View 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')
})
})

View 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)
})
})

View 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()
})
})
})

View 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)
})
})
})

View 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()
})
})
})

View 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()
})
})
})

View File

@@ -0,0 +1,161 @@
import { render, screen } from '@testing-library/vue'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import AssetCard from '@/platform/assets/components/AssetCard.vue'
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: () => 0
})
}))
vi.mock('@/stores/assetDownloadStore', () => ({
useAssetDownloadStore: () => ({
isDownloadedThisSession: () => false,
acknowledgeAsset: vi.fn()
})
}))
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: () => ({
closeDialog: vi.fn()
})
}))
vi.mock('@/platform/assets/services/assetService', () => ({
assetService: {
deleteAsset: vi.fn()
}
}))
vi.mock('@/components/dialog/confirm/confirmDialog', () => ({
showConfirmDialog: vi.fn()
}))
vi.mock('@vueuse/core', async () => {
const actual = await vi.importActual<Record<string, unknown>>('@vueuse/core')
return {
...actual,
useImage: () => ({ isLoading: false, error: null })
}
})
const HASH = 'blake3:abc123def456'
const ORIGINAL_FILENAME = 'sunset_photo.png'
function createDisplayAsset(
overrides: Partial<AssetDisplayItem> = {}
): AssetDisplayItem {
return {
id: 'asset-1',
name: HASH,
asset_hash: HASH,
tags: ['input'],
preview_url: '/preview.png',
secondaryText: '',
badges: [],
stats: {},
user_metadata: {},
metadata: { filename: ORIGINAL_FILENAME },
...overrides
}
}
function renderCard(asset: AssetDisplayItem) {
setActivePinia(createPinia())
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} },
missingWarn: false,
fallbackWarn: false
})
return render(AssetCard, {
props: { asset, interactive: true },
global: {
plugins: [i18n],
stubs: {
AssetBadgeGroup: true,
IconGroup: true,
MoreButton: true,
StatusBadge: true,
Button: { template: '<button><slot /></button>' }
},
directives: {
tooltip: {}
}
}
})
}
describe('AssetCard', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('FE-228: filename rendering', () => {
it('renders the human-readable filename instead of asset_hash when asset.name equals asset_hash', () => {
const asset = createDisplayAsset()
renderCard(asset)
const heading = screen.getByRole('heading', { level: 3 })
expect(heading).toHaveTextContent(ORIGINAL_FILENAME)
expect(heading).not.toHaveTextContent(HASH)
})
it('falls back to display_name when user_metadata.filename and metadata.filename are absent', () => {
const asset = createDisplayAsset({
metadata: undefined,
user_metadata: undefined,
display_name: ORIGINAL_FILENAME
})
renderCard(asset)
const heading = screen.getByRole('heading', { level: 3 })
expect(heading).toHaveTextContent(ORIGINAL_FILENAME)
expect(heading).not.toHaveTextContent(HASH)
})
})
describe('preserves user-curated display name', () => {
const CURATED_NAME = 'My Favorite SDXL LoRA'
const MODEL_FILENAME = 'lora_v1_epoch4.safetensors'
it('renders the curated name (user_metadata.name) when it differs from the raw asset.name', () => {
const asset = createDisplayAsset({
id: 'model-1',
name: MODEL_FILENAME,
asset_hash: undefined,
tags: ['models', 'loras'],
user_metadata: { name: CURATED_NAME },
metadata: { filename: MODEL_FILENAME }
})
renderCard(asset)
const heading = screen.getByRole('heading', { level: 3 })
expect(heading).toHaveTextContent(CURATED_NAME)
expect(heading).not.toHaveTextContent(MODEL_FILENAME)
})
it('ignores user_metadata.name that duplicates the hash and falls back to metadata.filename', () => {
const asset = createDisplayAsset({
name: HASH,
asset_hash: HASH,
user_metadata: { name: HASH },
metadata: { filename: ORIGINAL_FILENAME }
})
renderCard(asset)
const heading = screen.getByRole('heading', { level: 3 })
expect(heading).toHaveTextContent(ORIGINAL_FILENAME)
expect(heading).not.toHaveTextContent(HASH)
})
})
})

View File

@@ -143,7 +143,7 @@ import Button from '@/components/ui/button/Button.vue'
import AssetBadgeGroup from '@/platform/assets/components/AssetBadgeGroup.vue'
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
import { assetService } from '@/platform/assets/services/assetService'
import { getAssetDisplayName } from '@/platform/assets/utils/assetMetadataUtils'
import { getAssetCardTitle } from '@/platform/assets/utils/assetMetadataUtils'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
import { useDialogStore } from '@/stores/dialogStore'
@@ -174,7 +174,7 @@ const dropdownMenuButton = useTemplateRef<InstanceType<typeof MoreButton>>(
const titleId = useId()
const descId = useId()
const displayName = computed(() => getAssetDisplayName(asset))
const displayName = computed(() => getAssetCardTitle(asset))
const isNewlyImported = computed(() => isDownloadedThisSession(asset.id))

View File

@@ -2,7 +2,11 @@
<div class="inline-flex items-center">
<Popover>
<template #button>
<Button variant="secondary" size="icon">
<Button
variant="secondary"
size="icon"
:aria-label="$t('assetBrowser.filterBy')"
>
<i class="icon-[lucide--list-filter]" />
</Button>
</template>

View File

@@ -0,0 +1,93 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import MediaAssetFilterMenu from '@/platform/assets/components/MediaAssetFilterMenu.vue'
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key
})
}))
function renderMenu(mediaTypeFilters: string[] = []) {
const onUpdate = vi.fn()
const utils = render(MediaAssetFilterMenu, {
props: {
mediaTypeFilters,
'onUpdate:mediaTypeFilters': onUpdate
},
global: {
mocks: {
$t: (key: string) => key
}
}
})
return { ...utils, onUpdate, user: userEvent.setup() }
}
const labelByType: Record<string, string> = {
image: 'sideToolbar.mediaAssets.filterImage',
video: 'sideToolbar.mediaAssets.filterVideo',
audio: 'sideToolbar.mediaAssets.filterAudio',
'3d': 'sideToolbar.mediaAssets.filter3D'
}
function getCheckbox(type: keyof typeof labelByType): HTMLElement {
return screen.getByRole('checkbox', { name: labelByType[type] })
}
describe('MediaAssetFilterMenu', () => {
it('renders all four media-type checkboxes', () => {
renderMenu()
const checkboxes = screen.getAllByRole('checkbox')
expect(checkboxes).toHaveLength(4)
for (const type of Object.keys(labelByType)) {
expect(getCheckbox(type)).toBeTruthy()
}
})
it('reflects checked state from the prop via aria-checked', () => {
renderMenu(['image', '3d'])
expect(getCheckbox('image').getAttribute('aria-checked')).toBe('true')
expect(getCheckbox('3d').getAttribute('aria-checked')).toBe('true')
expect(getCheckbox('video').getAttribute('aria-checked')).toBe('false')
expect(getCheckbox('audio').getAttribute('aria-checked')).toBe('false')
})
it('emits an array containing the new type when an unchecked box is clicked', async () => {
const { onUpdate, user } = renderMenu([])
await user.click(getCheckbox('video'))
expect(onUpdate).toHaveBeenCalledTimes(1)
expect(onUpdate).toHaveBeenCalledWith(['video'])
})
it('emits an array without the type when a checked box is clicked again', async () => {
const { onUpdate, user } = renderMenu(['image', 'audio'])
await user.click(getCheckbox('audio'))
expect(onUpdate).toHaveBeenCalledWith(['image'])
})
it('appends to the existing filter list rather than replacing it', async () => {
const { onUpdate, user } = renderMenu(['image'])
await user.click(getCheckbox('video'))
expect(onUpdate).toHaveBeenCalledWith(['image', 'video'])
})
it('toggles via keyboard (Enter and Space)', async () => {
const { onUpdate, user } = renderMenu([])
getCheckbox('image').focus()
await user.keyboard('{Enter}')
expect(onUpdate).toHaveBeenLastCalledWith(['image'])
getCheckbox('audio').focus()
await user.keyboard(' ')
expect(onUpdate).toHaveBeenLastCalledWith(['audio'])
})
})

View File

@@ -0,0 +1,130 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it } from 'vitest'
import { defineComponent, ref } from 'vue'
import MediaAssetSettingsMenu from '@/platform/assets/components/MediaAssetSettingsMenu.vue'
import type { SortBy } from '@/platform/assets/components/MediaAssetSettingsMenu.vue'
const KEYS = {
list: 'sideToolbar.queueProgressOverlay.viewList',
grid: 'sideToolbar.queueProgressOverlay.viewGrid',
newest: 'sideToolbar.mediaAssets.sortNewestFirst',
oldest: 'sideToolbar.mediaAssets.sortOldestFirst',
longest: 'sideToolbar.mediaAssets.sortLongestFirst',
fastest: 'sideToolbar.mediaAssets.sortFastestFirst'
} as const
interface MountOptions {
viewMode?: 'list' | 'grid'
sortBy?: SortBy
showSortOptions?: boolean
showGenerationTimeSort?: boolean
}
function mountWithModels(options: MountOptions = {}) {
const viewMode = ref<'list' | 'grid'>(options.viewMode ?? 'list')
const sortBy = ref<SortBy>(options.sortBy ?? 'newest')
const Host = defineComponent({
components: { MediaAssetSettingsMenu },
setup() {
return {
viewMode,
sortBy,
showSortOptions: options.showSortOptions ?? false,
showGenerationTimeSort: options.showGenerationTimeSort ?? false
}
},
template: `
<MediaAssetSettingsMenu
v-model:viewMode="viewMode"
v-model:sortBy="sortBy"
:showSortOptions="showSortOptions"
:showGenerationTimeSort="showGenerationTimeSort"
/>
`
})
const utils = render(Host, {
global: {
mocks: {
$t: (key: string) => key
}
}
})
return { ...utils, viewMode, sortBy, user: userEvent.setup() }
}
function getButton(label: string): HTMLElement {
return screen.getByRole('button', { name: label })
}
describe('MediaAssetSettingsMenu', () => {
describe('view-mode options (always visible)', () => {
it('renders both list and grid view options', () => {
mountWithModels()
expect(getButton(KEYS.list)).toBeTruthy()
expect(getButton(KEYS.grid)).toBeTruthy()
})
it('updates the v-model:viewMode when an option is clicked', async () => {
const { viewMode, user } = mountWithModels({ viewMode: 'list' })
await user.click(getButton(KEYS.grid))
expect(viewMode.value).toBe('grid')
})
})
describe('sort options (gated by showSortOptions)', () => {
it('hides newest/oldest sort buttons when showSortOptions is false', () => {
mountWithModels({ showSortOptions: false })
expect(screen.queryByRole('button', { name: KEYS.newest })).toBeNull()
expect(screen.queryByRole('button', { name: KEYS.oldest })).toBeNull()
})
it('shows newest and oldest options when showSortOptions is true', () => {
mountWithModels({ showSortOptions: true })
expect(getButton(KEYS.newest)).toBeTruthy()
expect(getButton(KEYS.oldest)).toBeTruthy()
})
it('hides longest/fastest options unless showGenerationTimeSort is also true', () => {
mountWithModels({
showSortOptions: true,
showGenerationTimeSort: false
})
expect(screen.queryByRole('button', { name: KEYS.longest })).toBeNull()
expect(screen.queryByRole('button', { name: KEYS.fastest })).toBeNull()
})
it('shows generation-time options when both flags are true', () => {
mountWithModels({
showSortOptions: true,
showGenerationTimeSort: true
})
expect(getButton(KEYS.longest)).toBeTruthy()
expect(getButton(KEYS.fastest)).toBeTruthy()
})
})
describe('v-model:sortBy round-trip', () => {
const cases: Array<{ key: keyof typeof KEYS; expected: SortBy }> = [
{ key: 'newest', expected: 'newest' },
{ key: 'oldest', expected: 'oldest' },
{ key: 'longest', expected: 'longest' },
{ key: 'fastest', expected: 'fastest' }
]
for (const { key, expected } of cases) {
it(`emits ${expected} when ${key} is clicked`, async () => {
const { sortBy, user } = mountWithModels({
sortBy: 'newest',
showSortOptions: true,
showGenerationTimeSort: true
})
await user.click(getButton(KEYS[key]))
expect(sortBy.value).toBe(expected)
})
}
})
})

View File

@@ -0,0 +1,191 @@
import { describe, expect, it } from 'vitest'
import { ref } from 'vue'
import { useMediaAssetFiltering } from '@/platform/assets/composables/useMediaAssetFiltering'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
interface AssetSpec {
id: string
name: string
/** Unix ms; written into both `created_at` (ISO) and `user_metadata.create_time`. */
createTime?: number
/** Seconds, written into `user_metadata.executionTimeInSeconds`. */
executionSeconds?: number
}
function makeAsset(spec: AssetSpec): AssetItem {
const userMetadata: Record<string, unknown> = {}
if (spec.createTime !== undefined) {
userMetadata.create_time = spec.createTime
}
if (spec.executionSeconds !== undefined) {
userMetadata.executionTimeInSeconds = spec.executionSeconds
}
return {
id: spec.id,
name: spec.name,
tags: [],
created_at:
spec.createTime !== undefined
? new Date(spec.createTime).toISOString()
: undefined,
user_metadata: userMetadata
}
}
function ids(assets: AssetItem[]): string[] {
return assets.map((a) => a.id)
}
describe('useMediaAssetFiltering', () => {
describe('media-type filter', () => {
it('returns all assets when no filters are selected', () => {
const assets = ref<AssetItem[]>([
makeAsset({ id: 'a', name: 'a.png' }),
makeAsset({ id: 'b', name: 'b.mp4' }),
makeAsset({ id: 'c', name: 'c.glb' })
])
const { filteredAssets } = useMediaAssetFiltering(assets)
expect(ids(filteredAssets.value).sort()).toEqual(['a', 'b', 'c'])
})
it('filters to a single media kind', () => {
const assets = ref<AssetItem[]>([
makeAsset({ id: 'img', name: 'img.png' }),
makeAsset({ id: 'vid', name: 'vid.mp4' }),
makeAsset({ id: 'aud', name: 'aud.wav' }),
makeAsset({ id: '3d', name: 'model.glb' })
])
const { mediaTypeFilters, filteredAssets } =
useMediaAssetFiltering(assets)
mediaTypeFilters.value = ['video']
expect(ids(filteredAssets.value)).toEqual(['vid'])
})
it('combines multiple kinds via OR', () => {
const assets = ref<AssetItem[]>([
makeAsset({ id: 'img', name: 'img.png' }),
makeAsset({ id: 'vid', name: 'vid.mp4' }),
makeAsset({ id: 'aud', name: 'aud.wav' })
])
const { mediaTypeFilters, filteredAssets } =
useMediaAssetFiltering(assets)
mediaTypeFilters.value = ['image', 'audio']
expect(ids(filteredAssets.value).sort()).toEqual(['aud', 'img'])
})
it("normalizes '3D' filename detection to lowercase '3d' for filter match", () => {
// getMediaTypeFromFilename returns '3D' for .glb, but the filter array
// stores the lowercase '3d' the menu emits — composable must reconcile.
const assets = ref<AssetItem[]>([
makeAsset({ id: 'img', name: 'img.png' }),
makeAsset({ id: 'mesh', name: 'mesh.glb' })
])
const { mediaTypeFilters, filteredAssets } =
useMediaAssetFiltering(assets)
mediaTypeFilters.value = ['3d']
expect(ids(filteredAssets.value)).toEqual(['mesh'])
})
it('excludes unsupported media kinds (e.g. text) when any filter is active', () => {
const assets = ref<AssetItem[]>([
makeAsset({ id: 'img', name: 'img.png' }),
makeAsset({ id: 'doc', name: 'notes.txt' })
])
const { mediaTypeFilters, filteredAssets } =
useMediaAssetFiltering(assets)
mediaTypeFilters.value = ['image']
expect(ids(filteredAssets.value)).toEqual(['img'])
})
})
describe('sort', () => {
const t1 = 1_000_000
const t2 = 2_000_000
const t3 = 3_000_000
it('defaults to newest first by create_time descending', () => {
const assets = ref<AssetItem[]>([
makeAsset({ id: 'old', name: 'a.png', createTime: t1 }),
makeAsset({ id: 'mid', name: 'b.png', createTime: t2 }),
makeAsset({ id: 'new', name: 'c.png', createTime: t3 })
])
const { filteredAssets } = useMediaAssetFiltering(assets)
expect(ids(filteredAssets.value)).toEqual(['new', 'mid', 'old'])
})
it('sorts oldest first by create_time ascending', () => {
const assets = ref<AssetItem[]>([
makeAsset({ id: 'new', name: 'c.png', createTime: t3 }),
makeAsset({ id: 'old', name: 'a.png', createTime: t1 }),
makeAsset({ id: 'mid', name: 'b.png', createTime: t2 })
])
const { sortBy, filteredAssets } = useMediaAssetFiltering(assets)
sortBy.value = 'oldest'
expect(ids(filteredAssets.value)).toEqual(['old', 'mid', 'new'])
})
it('sorts longest by executionTimeInSeconds descending', () => {
const assets = ref<AssetItem[]>([
makeAsset({ id: 'fast', name: 'a.png', executionSeconds: 3 }),
makeAsset({ id: 'slow', name: 'b.png', executionSeconds: 10 }),
makeAsset({ id: 'mid', name: 'c.png', executionSeconds: 5 })
])
const { sortBy, filteredAssets } = useMediaAssetFiltering(assets)
sortBy.value = 'longest'
expect(ids(filteredAssets.value)).toEqual(['slow', 'mid', 'fast'])
})
it('sorts fastest by executionTimeInSeconds ascending', () => {
const assets = ref<AssetItem[]>([
makeAsset({ id: 'fast', name: 'a.png', executionSeconds: 3 }),
makeAsset({ id: 'slow', name: 'b.png', executionSeconds: 10 }),
makeAsset({ id: 'mid', name: 'c.png', executionSeconds: 5 })
])
const { sortBy, filteredAssets } = useMediaAssetFiltering(assets)
sortBy.value = 'fastest'
expect(ids(filteredAssets.value)).toEqual(['fast', 'mid', 'slow'])
})
it('falls back to created_at when user_metadata.create_time is absent', () => {
const a = makeAsset({ id: 'a', name: 'a.png', createTime: t1 })
const b = makeAsset({ id: 'b', name: 'b.png', createTime: t2 })
// Strip the user_metadata.create_time path on both, leaving created_at.
a.user_metadata = {}
b.user_metadata = {}
const assets = ref<AssetItem[]>([a, b])
const { filteredAssets } = useMediaAssetFiltering(assets)
expect(ids(filteredAssets.value)).toEqual(['b', 'a'])
})
})
describe('composition', () => {
it('applies media-type filter then sort', () => {
const t1 = 1_000_000
const t2 = 2_000_000
const t3 = 3_000_000
const assets = ref<AssetItem[]>([
makeAsset({ id: 'img-old', name: 'a.png', createTime: t1 }),
makeAsset({ id: 'vid', name: 'b.mp4', createTime: t2 }),
makeAsset({ id: 'img-new', name: 'c.png', createTime: t3 })
])
const { mediaTypeFilters, sortBy, filteredAssets } =
useMediaAssetFiltering(assets)
mediaTypeFilters.value = ['image']
sortBy.value = 'oldest'
expect(ids(filteredAssets.value)).toEqual(['img-old', 'img-new'])
})
})
})

View File

@@ -5,8 +5,11 @@ import {
getAssetAdditionalTags,
getAssetBaseModel,
getAssetBaseModels,
getAssetCardTitle,
getAssetDescription,
getAssetDisplayFilename,
getAssetDisplayName,
getAssetFilename,
getAssetModelType,
getAssetSourceUrl,
getAssetTriggerPhrases,
@@ -291,4 +294,93 @@ describe('assetMetadataUtils', () => {
expect(getAssetUserDescription(mockAsset)).toBe('')
})
})
describe('getAssetFilename', () => {
it('returns user_metadata.filename when present', () => {
const asset = {
...mockAsset,
user_metadata: { filename: 'from_user.png' },
metadata: { filename: 'from_meta.png' },
display_name: 'from_display.png'
}
expect(getAssetFilename(asset)).toBe('from_user.png')
})
it('falls through to metadata.filename then asset.name (never display_name)', () => {
const asset = {
...mockAsset,
user_metadata: {},
metadata: {},
display_name: 'from_display.png'
}
expect(getAssetFilename(asset)).toBe(mockAsset.name)
})
})
describe('getAssetDisplayFilename', () => {
it('prefers user_metadata.filename over everything else', () => {
const asset = {
...mockAsset,
user_metadata: { filename: 'from_user.png' },
metadata: { filename: 'from_meta.png' },
display_name: 'from_display.png'
}
expect(getAssetDisplayFilename(asset)).toBe('from_user.png')
})
it('falls back to display_name when filename metadata is absent', () => {
const asset = {
...mockAsset,
user_metadata: {},
metadata: {},
display_name: 'ComfyUI_00001_.png'
}
expect(getAssetDisplayFilename(asset)).toBe('ComfyUI_00001_.png')
})
it('falls back to asset.name when neither filename metadata nor display_name exist', () => {
expect(getAssetDisplayFilename(mockAsset)).toBe(mockAsset.name)
})
})
describe('getAssetCardTitle', () => {
it('returns user_metadata.name when it differs from asset.name', () => {
const asset = {
...mockAsset,
name: 'lora_v1.safetensors',
user_metadata: { name: 'My Favorite LoRA' },
metadata: { filename: 'lora_v1.safetensors' }
}
expect(getAssetCardTitle(asset)).toBe('My Favorite LoRA')
})
it('returns metadata.name when user_metadata.name is absent and it differs from asset.name', () => {
const asset = {
...mockAsset,
name: 'model_file.safetensors',
metadata: { name: 'Curated Model' }
}
expect(getAssetCardTitle(asset)).toBe('Curated Model')
})
it('falls through to the filename helper when curated name equals asset.name (hash case)', () => {
const HASH = 'blake3:abc'
const asset = {
...mockAsset,
name: HASH,
user_metadata: { name: HASH },
metadata: { filename: 'sunset.png' }
}
expect(getAssetCardTitle(asset)).toBe('sunset.png')
})
it('falls through to display_name when neither curated name nor filename metadata exist', () => {
const asset = {
...mockAsset,
name: 'hash.png',
display_name: 'pretty.png'
}
expect(getAssetCardTitle(asset)).toBe('pretty.png')
})
})
})

View File

@@ -168,10 +168,39 @@ export function getAssetUserDescription(asset: AssetItem): string {
/**
* Gets the filename for an asset with fallback chain
* Checks user_metadata.filename first, then metadata.filename, then asset.name
* @param asset - The asset to extract filename from
* @returns The filename string
* Checks user_metadata.filename first, then metadata.filename, then asset.name.
* Use this for serialized/identifier contexts (workflow widget values,
* filename schema validation, missing-model matching) where we need the
* canonical filename and MUST NOT substitute a display-only string.
*/
export function getAssetFilename(asset: AssetItem): string {
return getStringProperty(asset, 'filename') ?? asset.name
}
/**
* Gets the human-readable filename to render in UI surfaces.
* Fallback chain: user_metadata.filename → metadata.filename →
* asset.display_name → asset.name.
*
* `display_name` is populated by queue output mappers in Cloud where
* `asset.name` is a content hash. Use this helper for labels/titles only;
* for serialized identifiers use {@link getAssetFilename}.
*/
export function getAssetDisplayFilename(asset: AssetItem): string {
return (
getStringProperty(asset, 'filename') ?? asset.display_name ?? asset.name
)
}
/**
* Gets the title to render on an asset browser card / delete confirmation.
* Prefers a user-curated name (user_metadata.name / metadata.name) when it
* actually differs from asset.name, so a user-renamed model keeps its
* display name. Falls through to {@link getAssetDisplayFilename} when the
* curated name is absent or equal to asset.name (Cloud hash case).
*/
export function getAssetCardTitle(asset: AssetItem): string {
const curatedName = getStringProperty(asset, 'name')
if (curatedName && curatedName !== asset.name) return curatedName
return getAssetDisplayFilename(asset)
}

View File

@@ -683,6 +683,70 @@ describe('useWidgetSelectItems', () => {
})
})
describe('FE-228: output dropdown label uses human-readable filename', () => {
it('renders metadata.filename in label when asset.name is a hash', async () => {
mockMediaAssets.media.value = [
{
id: 'asset-hash-1',
name: 'a1ef7d292026e89ce9bbbd8093e2d0ed6a8850361a0c22e49522ac7baa5494e5.png',
asset_hash:
'a1ef7d292026e89ce9bbbd8093e2d0ed6a8850361a0c22e49522ac7baa5494e5',
preview_url: '/preview.png',
tags: ['output'],
metadata: {
filename: 'sunset_photo.png'
}
}
]
const { dropdownItems, filterSelected } = useWidgetSelectItems(
createDefaultOptions({
values: () => [],
modelValue: ref(undefined)
})
)
filterSelected.value = 'outputs'
await nextTick()
expect(dropdownItems.value).toHaveLength(1)
expect(dropdownItems.value[0].label).toBe('sunset_photo.png [output]')
})
it('renders asset.display_name in label when queue-mapped asset lacks metadata.filename', async () => {
mockMediaAssets.media.value = [
{
id: 'job-1',
name: 'a1ef7d292026e89ce9bbbd8093e2d0ed6a8850361a0c22e49522ac7baa5494e5.png',
display_name: 'ComfyUI-90_right_00001_.png',
preview_url: '/preview.png',
tags: ['output'],
user_metadata: {
jobId: 'job-1',
nodeId: '5',
subfolder: ''
}
}
]
const { dropdownItems, filterSelected } = useWidgetSelectItems(
createDefaultOptions({
values: () => [],
modelValue: ref(undefined)
})
)
filterSelected.value = 'outputs'
await nextTick()
expect(dropdownItems.value).toHaveLength(1)
expect(dropdownItems.value[0].label).toBe(
'ComfyUI-90_right_00001_.png [output]'
)
expect(dropdownItems.value[0].name).toMatch(
/^a1ef7d29.*\.png \[output\]$/
)
})
})
describe('selectedSet', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))

View File

@@ -11,6 +11,7 @@ import {
} from '@/platform/assets/utils/assetFilterUtils'
import {
getAssetBaseModels,
getAssetDisplayFilename,
getAssetDisplayName,
getAssetFilename
} from '@/platform/assets/utils/assetMetadataUtils'
@@ -180,6 +181,7 @@ export function useWidgetSelectItems(options: UseWidgetSelectItemsOptions) {
if (seen.has(asset.id)) continue
seen.add(asset.id)
const annotatedPath = `${asset.name} [output]`
const displayLabel = `${getAssetDisplayFilename(asset)} [output]`
items.push({
id: `output-${asset.id}`,
preview_url:
@@ -187,7 +189,7 @@ export function useWidgetSelectItems(options: UseWidgetSelectItemsOptions) {
? ''
: asset.preview_url || getMediaUrl(asset.name, 'output', kind),
name: annotatedPath,
label: getDisplayLabel(annotatedPath, labelFn)
label: getDisplayLabel(displayLabel, labelFn)
})
}

View File

@@ -0,0 +1,159 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useMaskEditorDataStore } from '@/stores/maskEditorDataStore'
import type { EditorOutputData } from '@/stores/maskEditorDataStore'
const createImage = (): HTMLImageElement => document.createElement('img')
const createCanvas = (): HTMLCanvasElement => document.createElement('canvas')
const createOutputData = (): EditorOutputData => {
const blob = new Blob()
const ref = { filename: 'out.png' }
return {
maskedImage: { canvas: createCanvas(), blob, ref },
paintLayer: { canvas: createCanvas(), blob, ref },
paintedImage: { canvas: createCanvas(), blob, ref },
paintedMaskedImage: { canvas: createCanvas(), blob, ref }
}
}
describe('maskEditorDataStore', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
describe('hasValidInput', () => {
it('should be false when inputData is null', () => {
const store = useMaskEditorDataStore()
expect(store.hasValidInput).toBe(false)
})
it('should be true when inputData is set', () => {
const store = useMaskEditorDataStore()
store.inputData = {
baseLayer: { image: createImage(), url: 'base' },
maskLayer: { image: createImage(), url: 'mask' },
sourceRef: { filename: 'src.png' },
nodeId: 1
}
expect(store.hasValidInput).toBe(true)
})
})
describe('hasValidOutput', () => {
it('should be false when outputData is null', () => {
const store = useMaskEditorDataStore()
expect(store.hasValidOutput).toBe(false)
})
it('should be true when outputData is set', () => {
const store = useMaskEditorDataStore()
store.outputData = createOutputData()
expect(store.hasValidOutput).toBe(true)
})
})
describe('isReady', () => {
it('should be false without input', () => {
const store = useMaskEditorDataStore()
expect(store.isReady).toBe(false)
})
it('should be true with input and not loading', () => {
const store = useMaskEditorDataStore()
store.inputData = {
baseLayer: { image: createImage(), url: 'base' },
maskLayer: { image: createImage(), url: 'mask' },
sourceRef: { filename: 'src.png' },
nodeId: 1
}
expect(store.isReady).toBe(true)
})
it('should be false when loading even if input is set', () => {
const store = useMaskEditorDataStore()
store.inputData = {
baseLayer: { image: createImage(), url: 'base' },
maskLayer: { image: createImage(), url: 'mask' },
sourceRef: { filename: 'src.png' },
nodeId: 1
}
store.isLoading = true
expect(store.isReady).toBe(false)
})
})
describe('setLoading', () => {
it('should toggle isLoading without touching loadError when no error provided', () => {
const store = useMaskEditorDataStore()
store.loadError = 'previous'
store.setLoading(true)
expect(store.isLoading).toBe(true)
expect(store.loadError).toBe('previous')
})
it('should set loadError when an error string is provided', () => {
const store = useMaskEditorDataStore()
store.setLoading(false, 'failed to load')
expect(store.isLoading).toBe(false)
expect(store.loadError).toBe('failed to load')
})
it('should not clear an existing loadError when called with empty string', () => {
const store = useMaskEditorDataStore()
store.loadError = 'previous'
store.setLoading(false, '')
expect(store.loadError).toBe('previous')
})
})
describe('reset', () => {
it('should clear all state back to defaults', () => {
const store = useMaskEditorDataStore()
store.inputData = {
baseLayer: { image: createImage(), url: 'base' },
maskLayer: { image: createImage(), url: 'mask' },
paintLayer: { image: createImage(), url: 'paint' },
sourceRef: { filename: 'src.png', subfolder: 'sub', type: 'input' },
nodeId: 42
}
store.outputData = createOutputData()
store.sourceNode = { id: 42 } as LGraphNode
store.isLoading = true
store.loadError = 'something broke'
store.reset()
expect(store.inputData).toBeNull()
expect(store.outputData).toBeNull()
expect(store.sourceNode).toBeNull()
expect(store.isLoading).toBe(false)
expect(store.loadError).toBeNull()
expect(store.hasValidInput).toBe(false)
expect(store.hasValidOutput).toBe(false)
expect(store.isReady).toBe(false)
})
})
})

View File

@@ -0,0 +1,376 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import {
BrushShape,
ColorComparisonMethod,
MaskBlendMode,
Tools
} from '@/extensions/core/maskeditor/types'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
const mockHistory = vi.hoisted(() => ({
canUndo: { value: false },
canRedo: { value: false }
}))
vi.mock('@/composables/maskeditor/useCanvasHistory', () => ({
useCanvasHistory: vi.fn(() => mockHistory)
}))
const makeCanvas = (): HTMLCanvasElement => {
const canvas = document.createElement('canvas')
canvas.getContext = vi
.fn()
.mockReturnValue({ fake: true }) as HTMLCanvasElement['getContext']
return canvas
}
describe('maskEditorStore', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
mockHistory.canUndo.value = false
mockHistory.canRedo.value = false
})
describe('brush setters', () => {
it('should clamp brush size between 1 and 250', () => {
const store = useMaskEditorStore()
store.setBrushSize(0)
expect(store.brushSettings.size).toBe(1)
store.setBrushSize(500)
expect(store.brushSettings.size).toBe(250)
store.setBrushSize(42)
expect(store.brushSettings.size).toBe(42)
})
it('should clamp brush opacity between 0 and 1', () => {
const store = useMaskEditorStore()
store.setBrushOpacity(-0.5)
expect(store.brushSettings.opacity).toBe(0)
store.setBrushOpacity(2)
expect(store.brushSettings.opacity).toBe(1)
store.setBrushOpacity(0.3)
expect(store.brushSettings.opacity).toBe(0.3)
})
it('should clamp brush hardness between 0 and 1', () => {
const store = useMaskEditorStore()
store.setBrushHardness(-1)
expect(store.brushSettings.hardness).toBe(0)
store.setBrushHardness(5)
expect(store.brushSettings.hardness).toBe(1)
})
it('should clamp brush step size between 1 and 100', () => {
const store = useMaskEditorStore()
store.setBrushStepSize(0)
expect(store.brushSettings.stepSize).toBe(1)
store.setBrushStepSize(500)
expect(store.brushSettings.stepSize).toBe(100)
})
})
describe('resetBrushToDefault', () => {
it('should restore the documented default brush', () => {
const store = useMaskEditorStore()
store.setBrushSize(123)
store.setBrushOpacity(0.1)
store.resetBrushToDefault()
expect(store.brushSettings).toEqual({
type: BrushShape.Arc,
size: 20,
opacity: 1,
hardness: 1,
stepSize: 5
})
})
})
describe('numeric setters with clamping', () => {
it('should clamp paintBucket tolerance between 0 and 255', () => {
const store = useMaskEditorStore()
store.setPaintBucketTolerance(-1)
expect(store.paintBucketTolerance).toBe(0)
store.setPaintBucketTolerance(999)
expect(store.paintBucketTolerance).toBe(255)
})
it('should clamp fill opacity between 0 and 100', () => {
const store = useMaskEditorStore()
store.setFillOpacity(-10)
expect(store.fillOpacity).toBe(0)
store.setFillOpacity(200)
expect(store.fillOpacity).toBe(100)
})
it('should clamp colorSelectTolerance between 0 and 255', () => {
const store = useMaskEditorStore()
store.setColorSelectTolerance(-5)
expect(store.colorSelectTolerance).toBe(0)
store.setColorSelectTolerance(999)
expect(store.colorSelectTolerance).toBe(255)
})
it('should clamp maskTolerance between 0 and 255', () => {
const store = useMaskEditorStore()
store.setMaskTolerance(-1)
expect(store.maskTolerance).toBe(0)
store.setMaskTolerance(500)
expect(store.maskTolerance).toBe(255)
})
it('should clamp selectionOpacity between 0 and 100', () => {
const store = useMaskEditorStore()
store.setSelectionOpacity(-1)
expect(store.selectionOpacity).toBe(0)
store.setSelectionOpacity(500)
expect(store.selectionOpacity).toBe(100)
})
it('should clamp maskOpacity between 0 and 1', () => {
const store = useMaskEditorStore()
store.setMaskOpacity(-0.5)
expect(store.maskOpacity).toBe(0)
store.setMaskOpacity(2)
expect(store.maskOpacity).toBe(1)
})
it('should clamp zoomRatio between 0.1 and 10', () => {
const store = useMaskEditorStore()
store.setZoomRatio(0.001)
expect(store.zoomRatio).toBe(0.1)
store.setZoomRatio(100)
expect(store.zoomRatio).toBe(10)
store.setZoomRatio(2.5)
expect(store.zoomRatio).toBe(2.5)
})
})
describe('setPanOffset / setCursorPoint', () => {
it('should copy pan offset by value, not by reference', () => {
const store = useMaskEditorStore()
const offset = { x: 10, y: 20 }
store.setPanOffset(offset)
offset.x = 999
expect(store.panOffset.x).toBe(10)
})
it('should copy cursor point by value, not by reference', () => {
const store = useMaskEditorStore()
const point = { x: 5, y: 7 }
store.setCursorPoint(point)
point.y = 999
expect(store.cursorPoint.y).toBe(7)
})
})
describe('triggers', () => {
it('should bump resetZoomTrigger each time resetZoom is called', () => {
const store = useMaskEditorStore()
const start = store.resetZoomTrigger
store.resetZoom()
store.resetZoom()
expect(store.resetZoomTrigger).toBe(start + 2)
})
it('should bump clearTrigger each time triggerClear is called', () => {
const store = useMaskEditorStore()
const start = store.clearTrigger
store.triggerClear()
store.triggerClear()
store.triggerClear()
expect(store.clearTrigger).toBe(start + 3)
})
})
describe('maskColor computed', () => {
it('should be black for MaskBlendMode.Black', () => {
const store = useMaskEditorStore()
store.maskBlendMode = MaskBlendMode.Black
expect(store.maskColor).toEqual({ r: 0, g: 0, b: 0 })
})
it('should be white for MaskBlendMode.White', () => {
const store = useMaskEditorStore()
store.maskBlendMode = MaskBlendMode.White
expect(store.maskColor).toEqual({ r: 255, g: 255, b: 255 })
})
it('should be white for MaskBlendMode.Negative', () => {
const store = useMaskEditorStore()
store.maskBlendMode = MaskBlendMode.Negative
expect(store.maskColor).toEqual({ r: 255, g: 255, b: 255 })
})
it('should fall back to black for an unknown blend mode', () => {
const store = useMaskEditorStore()
store.maskBlendMode = 'unrecognized' as MaskBlendMode
expect(store.maskColor).toEqual({ r: 0, g: 0, b: 0 })
})
})
describe('canUndo / canRedo proxies', () => {
it('should reflect canvasHistory.canUndo when it flips', () => {
const store = useMaskEditorStore()
mockHistory.canUndo.value = true
expect(store.canUndo).toBe(true)
})
it('should reflect canvasHistory.canRedo when it flips', () => {
const store = useMaskEditorStore()
mockHistory.canRedo.value = true
expect(store.canRedo).toBe(true)
})
})
describe('canvas → ctx watchers', () => {
it.each([
['maskCanvas', 'maskCtx'],
['rgbCanvas', 'rgbCtx'],
['imgCanvas', 'imgCtx']
] as const)(
'should derive %s using getContext with willReadFrequently',
async (canvasKey, ctxKey) => {
const store = useMaskEditorStore()
const canvas = makeCanvas()
store[canvasKey] = canvas
await nextTick()
expect(canvas.getContext).toHaveBeenCalledWith('2d', {
willReadFrequently: true
})
expect(store[ctxKey]).not.toBeNull()
}
)
it.each([
['maskCanvas', 'maskCtx'],
['rgbCanvas', 'rgbCtx'],
['imgCanvas', 'imgCtx']
] as const)(
'should leave existing %s ctx untouched when canvas is cleared',
async (canvasKey, ctxKey) => {
const store = useMaskEditorStore()
const canvas = makeCanvas()
store[canvasKey] = canvas
await nextTick()
const ctx = store[ctxKey]
store[canvasKey] = null
await nextTick()
expect(store[ctxKey]).toBe(ctx)
}
)
})
describe('resetState', () => {
it('should restore non-DOM state to documented defaults', () => {
const store = useMaskEditorStore()
store.setBrushSize(200)
store.maskBlendMode = MaskBlendMode.White
store.activeLayer = 'rgb'
store.rgbColor = '#00FF00'
store.currentTool = Tools.PaintPen
store.isAdjustingBrush = true
store.setPaintBucketTolerance(50)
store.setFillOpacity(20)
store.setColorSelectTolerance(80)
store.colorSelectLivePreview = true
store.colorComparisonMethod = ColorComparisonMethod.LAB
store.applyWholeImage = true
store.maskBoundary = true
store.setMaskTolerance(30)
store.setSelectionOpacity(50)
store.setZoomRatio(3)
store.setPanOffset({ x: 10, y: 20 })
store.setCursorPoint({ x: 5, y: 5 })
store.setMaskOpacity(0.2)
store.gpuTexturesNeedRecreation = true
store.gpuTextureWidth = 100
store.gpuTextureHeight = 200
store.resetState()
expect(store.brushSettings).toEqual({
type: BrushShape.Arc,
size: 10,
opacity: 0.7,
hardness: 1,
stepSize: 5
})
expect(store.maskBlendMode).toBe(MaskBlendMode.Black)
expect(store.activeLayer).toBe('mask')
expect(store.rgbColor).toBe('#FF0000')
expect(store.currentTool).toBe(Tools.MaskPen)
expect(store.isAdjustingBrush).toBe(false)
expect(store.paintBucketTolerance).toBe(5)
expect(store.fillOpacity).toBe(100)
expect(store.colorSelectTolerance).toBe(20)
expect(store.colorSelectLivePreview).toBe(false)
expect(store.colorComparisonMethod).toBe(ColorComparisonMethod.Simple)
expect(store.applyWholeImage).toBe(false)
expect(store.maskBoundary).toBe(false)
expect(store.maskTolerance).toBe(0)
expect(store.selectionOpacity).toBe(100)
expect(store.zoomRatio).toBe(1)
expect(store.panOffset).toEqual({ x: 0, y: 0 })
expect(store.cursorPoint).toEqual({ x: 0, y: 0 })
expect(store.maskOpacity).toBe(0.8)
expect(store.gpuTexturesNeedRecreation).toBe(false)
expect(store.gpuTextureWidth).toBe(0)
expect(store.gpuTextureHeight).toBe(0)
expect(store.pendingGPUMaskData).toBeNull()
expect(store.pendingGPURgbData).toBeNull()
})
it('should not clear DOM refs (canvases / pointerZone / image)', () => {
const store = useMaskEditorStore()
const canvas = document.createElement('canvas')
const zone = document.createElement('div')
const img = document.createElement('img')
store.maskCanvas = canvas
store.pointerZone = zone
store.image = img
store.resetState()
expect(store.maskCanvas).toBe(canvas)
expect(store.pointerZone).toBe(zone)
expect(store.image).toBe(img)
})
})
})