mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-06 06:01:58 +00:00
Compare commits
16 Commits
sno-fronte
...
test/cover
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3e2c6266f | ||
|
|
c594e30b84 | ||
|
|
2ade779a81 | ||
|
|
25f493bd30 | ||
|
|
b8b5e1ec1f | ||
|
|
59ef69f355 | ||
|
|
9ad052467d | ||
|
|
aa730c8cb5 | ||
|
|
cc1fe65348 | ||
|
|
0f66f76b87 | ||
|
|
bc16865019 | ||
|
|
206a367379 | ||
|
|
7e8ede376b | ||
|
|
7bfbd0d7f3 | ||
|
|
32b266f3e9 | ||
|
|
2d4ca9c387 |
@@ -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' })
|
||||
|
||||
@@ -7,26 +7,56 @@ const jobsListRoutePattern = /\/api\/jobs(?:\?.*)?$/
|
||||
const inputFilesRoutePattern = /\/internal\/files\/input(?:\?.*)?$/
|
||||
const historyRoutePattern = /\/api\/history$/
|
||||
|
||||
/**
|
||||
* Media kinds supported by the assets sidebar filter UI. The string values
|
||||
* match what the backend stores on `preview_output.mediaType` (`images` is
|
||||
* intentionally plural to match existing API conventions; the others are
|
||||
* singular as emitted by `useMediaAssetGalleryStore`).
|
||||
*
|
||||
* The sidebar filter ultimately matches on the filename extension, so the
|
||||
* fixture also picks an extension-appropriate filename for each kind.
|
||||
*/
|
||||
export type MediaKindFixture = 'images' | 'video' | 'audio' | '3D'
|
||||
|
||||
const DEFAULT_EXTENSION: Record<MediaKindFixture, string> = {
|
||||
images: 'png',
|
||||
video: 'mp4',
|
||||
audio: 'wav',
|
||||
'3D': 'glb'
|
||||
}
|
||||
|
||||
/** Factory to create a mock completed job with preview output. */
|
||||
export function createMockJob(
|
||||
overrides: Partial<RawJobListItem> & { id: string }
|
||||
overrides: Partial<RawJobListItem> & {
|
||||
id: string
|
||||
/**
|
||||
* Optional shorthand to set both `preview_output.mediaType` and an
|
||||
* extension-appropriate filename. Ignored when `preview_output` is also
|
||||
* supplied via `overrides`.
|
||||
*/
|
||||
mediaKind?: MediaKindFixture
|
||||
}
|
||||
): RawJobListItem {
|
||||
const { mediaKind, ...rest } = overrides
|
||||
const now = Date.now()
|
||||
const extension = mediaKind ? DEFAULT_EXTENSION[mediaKind] : 'png'
|
||||
const mediaType = mediaKind ?? 'images'
|
||||
|
||||
return {
|
||||
status: 'completed',
|
||||
create_time: now,
|
||||
execution_start_time: now,
|
||||
execution_end_time: now + 5000,
|
||||
preview_output: {
|
||||
filename: `output_${overrides.id}.png`,
|
||||
filename: `output_${rest.id}.${extension}`,
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
mediaType
|
||||
},
|
||||
outputs_count: 1,
|
||||
priority: 0,
|
||||
...overrides
|
||||
...rest
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +84,46 @@ export function createMockJobs(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create one job per requested media kind, in the order supplied. Jobs share
|
||||
* a stable timestamp ordering (newer first) so callers can rely on the result
|
||||
* order when mediaType filters are inactive.
|
||||
*/
|
||||
export function createMixedMediaJobs(
|
||||
kinds: MediaKindFixture[]
|
||||
): RawJobListItem[] {
|
||||
const now = Date.now()
|
||||
return kinds.map((kind, i) =>
|
||||
createMockJob({
|
||||
id: `${kind}-${String(i + 1).padStart(3, '0')}`,
|
||||
mediaKind: kind,
|
||||
create_time: now - i * 60_000,
|
||||
execution_start_time: now - i * 60_000,
|
||||
execution_end_time: now - i * 60_000 + 5000
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create jobs with explicit `(create_time, execution duration)` pairs so that
|
||||
* sort assertions for newest/oldest and longest/fastest are unambiguous.
|
||||
*
|
||||
* Each spec entry yields a job whose `execution_end_time - execution_start_time`
|
||||
* equals `durationMs`. The first spec becomes id `job-001`, etc.
|
||||
*/
|
||||
export function createJobsWithExecutionTimes(
|
||||
specs: ReadonlyArray<{ createTime: number; durationMs: number }>
|
||||
): RawJobListItem[] {
|
||||
return specs.map((spec, i) =>
|
||||
createMockJob({
|
||||
id: `job-${String(i + 1).padStart(3, '0')}`,
|
||||
create_time: spec.createTime,
|
||||
execution_start_time: spec.createTime,
|
||||
execution_end_time: spec.createTime + spec.durationMs
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/** Create mock imported file names with various media types. */
|
||||
export function createMockImportedFiles(count: number): string[] {
|
||||
const extensions = ['png', 'jpg', 'mp4', 'wav', 'glb', 'txt']
|
||||
|
||||
232
browser_tests/tests/sidebar/assets-filter.spec.ts
Normal file
232
browser_tests/tests/sidebar/assets-filter.spec.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type {
|
||||
Asset,
|
||||
JobsListResponse,
|
||||
ListAssetsResponse
|
||||
} from '@comfyorg/ingest-types'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import { createMixedMediaJobs } from '@e2e/fixtures/helpers/AssetsHelper'
|
||||
|
||||
// The assets sidebar's media-type filter menu only renders in cloud mode
|
||||
// (`MediaAssetFilterBar.vue` gates `MediaAssetFilterButton` behind `isCloud`).
|
||||
// We tag tests `@cloud` so they run against the cloud Playwright project,
|
||||
// and register both `/api/assets` and `/api/jobs` route handlers as auto
|
||||
// fixtures — Playwright runs auto fixtures before the `comfyPage` fixture's
|
||||
// internal `setup()`, so the page first-loads with mocks already in place.
|
||||
// See cloud-asset-default.spec.ts for the same pattern.
|
||||
|
||||
const MIXED_JOBS = createMixedMediaJobs(['images', 'video', 'audio', '3D'])
|
||||
|
||||
// MediaAssetCard renders the filename *without* extension via
|
||||
// getFilenameDetails(...).filename, so card-text matching uses the basename.
|
||||
function expectCardText(index: number): string {
|
||||
const filename = MIXED_JOBS[index]?.preview_output?.filename
|
||||
if (!filename) {
|
||||
throw new Error(
|
||||
`MIXED_JOBS[${index}].preview_output.filename is missing — ` +
|
||||
'createMixedMediaJobs contract changed.'
|
||||
)
|
||||
}
|
||||
return filename.replace(/\.[^.]+$/, '')
|
||||
}
|
||||
|
||||
const imageCardName = expectCardText(0)
|
||||
const videoCardName = expectCardText(1)
|
||||
const audioCardName = expectCardText(2)
|
||||
const threeDCardName = expectCardText(3)
|
||||
|
||||
function makeAssetsResponse(assets: Asset[]): ListAssetsResponse {
|
||||
return { assets, total: assets.length, has_more: false }
|
||||
}
|
||||
|
||||
function makeJobsResponseBody() {
|
||||
return {
|
||||
jobs: MIXED_JOBS,
|
||||
pagination: {
|
||||
offset: 0,
|
||||
limit: MIXED_JOBS.length,
|
||||
total: MIXED_JOBS.length,
|
||||
has_more: false
|
||||
}
|
||||
} satisfies {
|
||||
jobs: unknown[]
|
||||
pagination: JobsListResponse['pagination']
|
||||
}
|
||||
}
|
||||
|
||||
const test = comfyPageFixture.extend<{
|
||||
stubCloudAssets: void
|
||||
stubJobs: void
|
||||
stubInputFiles: void
|
||||
}>({
|
||||
stubCloudAssets: [
|
||||
async ({ page }, use) => {
|
||||
const pattern = '**/api/assets?*'
|
||||
await page.route(pattern, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(makeAssetsResponse([]))
|
||||
})
|
||||
)
|
||||
await use()
|
||||
await page.unroute(pattern)
|
||||
},
|
||||
{ auto: true }
|
||||
],
|
||||
stubJobs: [
|
||||
async ({ page }, use) => {
|
||||
const pattern = /\/api\/jobs(?:\?.*)?$/
|
||||
await page.route(pattern, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(makeJobsResponseBody())
|
||||
})
|
||||
)
|
||||
await use()
|
||||
await page.unroute(pattern)
|
||||
},
|
||||
{ auto: true }
|
||||
],
|
||||
stubInputFiles: [
|
||||
async ({ page }, use) => {
|
||||
const pattern = /\/internal\/files\/input(?:\?.*)?$/
|
||||
await page.route(pattern, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([])
|
||||
})
|
||||
)
|
||||
await use()
|
||||
await page.unroute(pattern)
|
||||
},
|
||||
{ auto: true }
|
||||
]
|
||||
})
|
||||
|
||||
test.describe('Assets sidebar - media type filter', { tag: '@cloud' }, () => {
|
||||
test('Filter menu opens and exposes all four media-type checkboxes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets(MIXED_JOBS.length)
|
||||
|
||||
await tab.openFilterMenu()
|
||||
|
||||
await expect(tab.filterImageCheckbox).toBeVisible()
|
||||
await expect(tab.filterVideoCheckbox).toBeVisible()
|
||||
await expect(tab.filterAudioCheckbox).toBeVisible()
|
||||
await expect(tab.filter3DCheckbox).toBeVisible()
|
||||
for (const cb of [
|
||||
tab.filterImageCheckbox,
|
||||
tab.filterVideoCheckbox,
|
||||
tab.filterAudioCheckbox,
|
||||
tab.filter3DCheckbox
|
||||
]) {
|
||||
await expect(cb).toHaveAttribute('aria-checked', 'false')
|
||||
}
|
||||
})
|
||||
|
||||
test('Selecting only "Image" hides non-image assets', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets(MIXED_JOBS.length)
|
||||
|
||||
await tab.openFilterMenu()
|
||||
await tab.toggleMediaTypeFilter('image')
|
||||
|
||||
await expect(tab.assetCards).toHaveCount(1)
|
||||
await expect(tab.getAssetCardByName(imageCardName)).toBeVisible()
|
||||
await expect(tab.getAssetCardByName(videoCardName)).toHaveCount(0)
|
||||
await expect(tab.getAssetCardByName(audioCardName)).toHaveCount(0)
|
||||
await expect(tab.getAssetCardByName(threeDCardName)).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('Selecting only "Video" hides non-video assets', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets(MIXED_JOBS.length)
|
||||
|
||||
await tab.openFilterMenu()
|
||||
await tab.toggleMediaTypeFilter('video')
|
||||
|
||||
await expect(tab.assetCards).toHaveCount(1)
|
||||
await expect(tab.getAssetCardByName(videoCardName)).toBeVisible()
|
||||
})
|
||||
|
||||
test('Selecting only "Audio" hides non-audio assets', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets(MIXED_JOBS.length)
|
||||
|
||||
await tab.openFilterMenu()
|
||||
await tab.toggleMediaTypeFilter('audio')
|
||||
|
||||
await expect(tab.assetCards).toHaveCount(1)
|
||||
await expect(tab.getAssetCardByName(audioCardName)).toBeVisible()
|
||||
})
|
||||
|
||||
test('Selecting only "3D" hides non-3D assets', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets(MIXED_JOBS.length)
|
||||
|
||||
await tab.openFilterMenu()
|
||||
await tab.toggleMediaTypeFilter('3d')
|
||||
|
||||
await expect(tab.assetCards).toHaveCount(1)
|
||||
await expect(tab.getAssetCardByName(threeDCardName)).toBeVisible()
|
||||
})
|
||||
|
||||
test('Multiple filters combine via OR (image + video)', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets(MIXED_JOBS.length)
|
||||
|
||||
await tab.openFilterMenu()
|
||||
await tab.toggleMediaTypeFilter('image')
|
||||
await tab.toggleMediaTypeFilter('video')
|
||||
|
||||
await expect(tab.assetCards).toHaveCount(2)
|
||||
await expect(tab.getAssetCardByName(imageCardName)).toBeVisible()
|
||||
await expect(tab.getAssetCardByName(videoCardName)).toBeVisible()
|
||||
await expect(tab.getAssetCardByName(audioCardName)).toHaveCount(0)
|
||||
await expect(tab.getAssetCardByName(threeDCardName)).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('Unchecking the active filter restores previously hidden cards', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets(MIXED_JOBS.length)
|
||||
|
||||
await tab.openFilterMenu()
|
||||
await tab.toggleMediaTypeFilter('image')
|
||||
await expect(tab.assetCards).toHaveCount(1)
|
||||
|
||||
await tab.toggleMediaTypeFilter('image')
|
||||
|
||||
// TODO(#11635): the 3D preview card does not remount after a filter
|
||||
// toggle restores it (only image/video/audio reappear). Image, video,
|
||||
// and audio cover the restoration path; once #11635 is fixed, add the
|
||||
// 3D card back to this assertion list.
|
||||
await expect(tab.getAssetCardByName(imageCardName)).toBeVisible({
|
||||
timeout: 10_000
|
||||
})
|
||||
await expect(tab.getAssetCardByName(videoCardName)).toBeVisible()
|
||||
await expect(tab.getAssetCardByName(audioCardName)).toBeVisible()
|
||||
})
|
||||
})
|
||||
206
browser_tests/tests/sidebar/assets-sort.spec.ts
Normal file
206
browser_tests/tests/sidebar/assets-sort.spec.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type {
|
||||
Asset,
|
||||
JobsListResponse,
|
||||
ListAssetsResponse
|
||||
} from '@comfyorg/ingest-types'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import { createJobsWithExecutionTimes } from '@e2e/fixtures/helpers/AssetsHelper'
|
||||
|
||||
// The assets sidebar's sort options live inside the settings popover and are
|
||||
// only rendered in cloud mode (`MediaAssetFilterBar.vue`:
|
||||
// `:show-sort-options="isCloud"`). We tag tests `@cloud` so they run against
|
||||
// the cloud Playwright project, and register `/api/assets`, `/api/jobs`, and
|
||||
// `/internal/files/input` route handlers as auto fixtures — Playwright runs
|
||||
// auto fixtures before the `comfyPage` fixture's internal `setup()`, so the
|
||||
// page first-loads with mocks already in place.
|
||||
|
||||
// Three jobs whose `(create_time, duration)` axes are intentionally
|
||||
// misaligned so newest/oldest and longest/fastest sorts produce *different*
|
||||
// orderings — preventing false-pass tests where one ordering accidentally
|
||||
// satisfies another.
|
||||
//
|
||||
// spec create_time duration (ms)
|
||||
// ----------------------------------------
|
||||
// job-001 1000 5000 (oldest, mid duration)
|
||||
// job-002 2000 10000 (mid age, longest)
|
||||
// job-003 3000 3000 (newest, shortest)
|
||||
const SORT_JOBS = createJobsWithExecutionTimes([
|
||||
{ createTime: 1000, durationMs: 5000 },
|
||||
{ createTime: 2000, durationMs: 10000 },
|
||||
{ createTime: 3000, durationMs: 3000 }
|
||||
])
|
||||
|
||||
// MediaAssetCard renders the filename *without* extension via
|
||||
// getFilenameDetails(...).filename, so card-text matching uses the basename.
|
||||
const NAME_BY_ID: Record<string, string> = {
|
||||
'job-001': 'output_job-001',
|
||||
'job-002': 'output_job-002',
|
||||
'job-003': 'output_job-003'
|
||||
}
|
||||
|
||||
function makeAssetsResponse(assets: Asset[]): ListAssetsResponse {
|
||||
return { assets, total: assets.length, has_more: false }
|
||||
}
|
||||
|
||||
function makeJobsResponseBody() {
|
||||
return {
|
||||
jobs: SORT_JOBS,
|
||||
pagination: {
|
||||
offset: 0,
|
||||
limit: SORT_JOBS.length,
|
||||
total: SORT_JOBS.length,
|
||||
has_more: false
|
||||
}
|
||||
} satisfies {
|
||||
jobs: unknown[]
|
||||
pagination: JobsListResponse['pagination']
|
||||
}
|
||||
}
|
||||
|
||||
const test = comfyPageFixture.extend<{
|
||||
stubCloudAssets: void
|
||||
stubJobs: void
|
||||
stubInputFiles: void
|
||||
}>({
|
||||
stubCloudAssets: [
|
||||
async ({ page }, use) => {
|
||||
const pattern = '**/api/assets?*'
|
||||
await page.route(pattern, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(makeAssetsResponse([]))
|
||||
})
|
||||
)
|
||||
await use()
|
||||
await page.unroute(pattern)
|
||||
},
|
||||
{ auto: true }
|
||||
],
|
||||
stubJobs: [
|
||||
async ({ page }, use) => {
|
||||
const pattern = /\/api\/jobs(?:\?.*)?$/
|
||||
await page.route(pattern, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(makeJobsResponseBody())
|
||||
})
|
||||
)
|
||||
await use()
|
||||
await page.unroute(pattern)
|
||||
},
|
||||
{ auto: true }
|
||||
],
|
||||
stubInputFiles: [
|
||||
async ({ page }, use) => {
|
||||
const pattern = /\/internal\/files\/input(?:\?.*)?$/
|
||||
await page.route(pattern, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([])
|
||||
})
|
||||
)
|
||||
await use()
|
||||
await page.unroute(pattern)
|
||||
},
|
||||
{ auto: true }
|
||||
]
|
||||
})
|
||||
|
||||
test.describe('Assets sidebar - sort options', { tag: '@cloud' }, () => {
|
||||
test('Settings menu exposes all four sort options in cloud mode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets(SORT_JOBS.length)
|
||||
|
||||
await tab.openSettingsMenu()
|
||||
|
||||
await expect(tab.sortNewestFirst).toBeVisible()
|
||||
await expect(tab.sortOldestFirst).toBeVisible()
|
||||
await expect(tab.sortLongestFirst).toBeVisible()
|
||||
await expect(tab.sortFastestFirst).toBeVisible()
|
||||
})
|
||||
|
||||
test('Default order is newest first (descending create_time)', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets(SORT_JOBS.length)
|
||||
|
||||
// Cards should appear in the order: job-003, job-002, job-001
|
||||
await expect(tab.assetCards.nth(0)).toContainText(NAME_BY_ID['job-003'])
|
||||
await expect(tab.assetCards.nth(1)).toContainText(NAME_BY_ID['job-002'])
|
||||
await expect(tab.assetCards.nth(2)).toContainText(NAME_BY_ID['job-001'])
|
||||
})
|
||||
|
||||
test('"Oldest first" reverses the order', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets(SORT_JOBS.length)
|
||||
|
||||
await tab.openSettingsMenu()
|
||||
await tab.sortOldestFirst.click()
|
||||
|
||||
await expect(tab.assetCards.nth(0)).toContainText(NAME_BY_ID['job-001'])
|
||||
await expect(tab.assetCards.nth(1)).toContainText(NAME_BY_ID['job-002'])
|
||||
await expect(tab.assetCards.nth(2)).toContainText(NAME_BY_ID['job-003'])
|
||||
})
|
||||
|
||||
test('"Longest first" puts the slowest job at the top', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets(SORT_JOBS.length)
|
||||
|
||||
await tab.openSettingsMenu()
|
||||
await tab.sortLongestFirst.click()
|
||||
|
||||
// Expected: job-002 (10s), job-001 (5s), job-003 (3s)
|
||||
await expect(tab.assetCards.nth(0)).toContainText(NAME_BY_ID['job-002'])
|
||||
await expect(tab.assetCards.nth(1)).toContainText(NAME_BY_ID['job-001'])
|
||||
await expect(tab.assetCards.nth(2)).toContainText(NAME_BY_ID['job-003'])
|
||||
})
|
||||
|
||||
test('"Fastest first" puts the quickest job at the top', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets(SORT_JOBS.length)
|
||||
|
||||
await tab.openSettingsMenu()
|
||||
await tab.sortFastestFirst.click()
|
||||
|
||||
// Expected: job-003 (3s), job-001 (5s), job-002 (10s)
|
||||
await expect(tab.assetCards.nth(0)).toContainText(NAME_BY_ID['job-003'])
|
||||
await expect(tab.assetCards.nth(1)).toContainText(NAME_BY_ID['job-001'])
|
||||
await expect(tab.assetCards.nth(2)).toContainText(NAME_BY_ID['job-002'])
|
||||
})
|
||||
|
||||
test('Sort persists when the search input is edited', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets(SORT_JOBS.length)
|
||||
|
||||
await tab.openSettingsMenu()
|
||||
await tab.sortOldestFirst.click()
|
||||
|
||||
// Type a query that matches all three jobs, then clear it; sort order
|
||||
// must remain "oldest first".
|
||||
await tab.searchInput.fill('output_job')
|
||||
await tab.searchInput.fill('')
|
||||
|
||||
await expect(tab.assetCards.nth(0)).toContainText(NAME_BY_ID['job-001'])
|
||||
await expect(tab.assetCards.nth(2)).toContainText(NAME_BY_ID['job-003'])
|
||||
})
|
||||
})
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import MaskEditorButton from '@/components/graph/selectionToolbox/MaskEditorButton.vue'
|
||||
|
||||
const mockExecute = vi.hoisted(() => vi.fn())
|
||||
const mockSelectionState = vi.hoisted(() => ({
|
||||
isSingleImageNode: { value: true }
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/commandStore', () => ({
|
||||
useCommandStore: () => ({ execute: mockExecute })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/graph/useSelectionState', () => ({
|
||||
useSelectionState: () => mockSelectionState
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
globalInjection: true,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
commands: {
|
||||
Comfy_MaskEditor_OpenMaskEditor: { label: 'Open in Mask Editor' }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const renderButton = () =>
|
||||
render(MaskEditorButton, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
directives: { tooltip: () => {} }
|
||||
}
|
||||
})
|
||||
|
||||
describe('MaskEditorButton', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockSelectionState.isSingleImageNode = ref(true)
|
||||
})
|
||||
|
||||
it('should render with the localized aria-label when a single image node is selected', () => {
|
||||
renderButton()
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Open in Mask Editor' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide via v-show when no single image node is selected', () => {
|
||||
mockSelectionState.isSingleImageNode = ref(false)
|
||||
renderButton()
|
||||
|
||||
const btn = screen.getByLabelText('Open in Mask Editor', {
|
||||
selector: 'button'
|
||||
})
|
||||
expect(btn.getAttribute('style') ?? '').toContain('display: none')
|
||||
})
|
||||
|
||||
it('should execute the OpenMaskEditor command on click', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderButton()
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Open in Mask Editor' })
|
||||
)
|
||||
|
||||
expect(mockExecute).toHaveBeenCalledWith('Comfy.MaskEditor.OpenMaskEditor')
|
||||
})
|
||||
})
|
||||
178
src/components/maskeditor/BrushCursor.test.ts
Normal file
178
src/components/maskeditor/BrushCursor.test.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { reactive } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import BrushCursor from '@/components/maskeditor/BrushCursor.vue'
|
||||
import { BrushShape } from '@/extensions/core/maskeditor/types'
|
||||
|
||||
const initialMock = () =>
|
||||
reactive({
|
||||
brushVisible: true,
|
||||
brushPreviewGradientVisible: false,
|
||||
brushSettings: {
|
||||
type: BrushShape.Arc,
|
||||
size: 20,
|
||||
opacity: 0.7,
|
||||
hardness: 1,
|
||||
stepSize: 5
|
||||
},
|
||||
zoomRatio: 1,
|
||||
cursorPoint: { x: 100, y: 50 },
|
||||
panOffset: { x: 0, y: 0 }
|
||||
})
|
||||
|
||||
let mockStore: ReturnType<typeof initialMock>
|
||||
|
||||
vi.mock('@/stores/maskEditorStore', () => ({
|
||||
useMaskEditorStore: () => mockStore
|
||||
}))
|
||||
|
||||
const styleOf = (el: Element): string => el.getAttribute('style') ?? ''
|
||||
|
||||
const renderCursor = (containerRef?: HTMLElement) =>
|
||||
render(BrushCursor, {
|
||||
props: containerRef ? { containerRef } : {}
|
||||
})
|
||||
|
||||
const getBrushEl = (): HTMLElement => screen.getByTestId('brush-cursor')
|
||||
|
||||
const getGradientEl = (): HTMLElement =>
|
||||
screen.getByTestId('brush-cursor-gradient')
|
||||
|
||||
describe('BrushCursor', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
document.body.innerHTML = ''
|
||||
mockStore = initialMock()
|
||||
})
|
||||
|
||||
describe('opacity', () => {
|
||||
it('should be 1 when brushVisible is true', () => {
|
||||
renderCursor()
|
||||
expect(styleOf(getBrushEl())).toContain('opacity: 1')
|
||||
})
|
||||
|
||||
it('should be 0 when brushVisible is false', () => {
|
||||
mockStore.brushVisible = false
|
||||
renderCursor()
|
||||
expect(styleOf(getBrushEl())).toContain('opacity: 0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('size and shape', () => {
|
||||
it('should compute size as 2 * effectiveBrushSize * zoomRatio', () => {
|
||||
// size=20, hardness=1 → effective=20; zoom=2 → diameter = 80
|
||||
mockStore.brushSettings.size = 20
|
||||
mockStore.brushSettings.hardness = 1
|
||||
mockStore.zoomRatio = 2
|
||||
|
||||
renderCursor()
|
||||
|
||||
const style = styleOf(getBrushEl())
|
||||
expect(style).toContain('width: 80px')
|
||||
expect(style).toContain('height: 80px')
|
||||
})
|
||||
|
||||
it('should grow effective size when hardness drops below 1', () => {
|
||||
mockStore.brushSettings.size = 100
|
||||
mockStore.brushSettings.hardness = 0
|
||||
mockStore.zoomRatio = 1
|
||||
|
||||
renderCursor()
|
||||
|
||||
// effective = 100 * (1 + 1.0 * 0.5) = 150 → diameter = 300
|
||||
expect(styleOf(getBrushEl())).toContain('width: 300px')
|
||||
})
|
||||
|
||||
it('should use 50% borderRadius for Arc brush', () => {
|
||||
mockStore.brushSettings.type = BrushShape.Arc
|
||||
renderCursor()
|
||||
expect(styleOf(getBrushEl())).toContain('border-radius: 50%')
|
||||
})
|
||||
|
||||
it('should use 0% borderRadius for Rect brush', () => {
|
||||
mockStore.brushSettings.type = BrushShape.Rect
|
||||
renderCursor()
|
||||
expect(styleOf(getBrushEl())).toContain('border-radius: 0%')
|
||||
})
|
||||
})
|
||||
|
||||
describe('position', () => {
|
||||
it('should anchor to cursorPoint plus panOffset minus radius (no container)', () => {
|
||||
mockStore.cursorPoint = { x: 200, y: 300 }
|
||||
mockStore.panOffset = { x: 50, y: 25 }
|
||||
mockStore.brushSettings.size = 20
|
||||
mockStore.brushSettings.hardness = 1
|
||||
mockStore.zoomRatio = 1
|
||||
|
||||
renderCursor()
|
||||
|
||||
// radius = effective(20,1) * 1 = 20
|
||||
// left = 200 + 50 - 20 = 230
|
||||
// top = 300 + 25 - 20 = 305
|
||||
const style = styleOf(getBrushEl())
|
||||
expect(style).toContain('left: 230px')
|
||||
expect(style).toContain('top: 305px')
|
||||
})
|
||||
|
||||
it('should subtract container offset when containerRef is provided', () => {
|
||||
mockStore.cursorPoint = { x: 200, y: 300 }
|
||||
mockStore.panOffset = { x: 0, y: 0 }
|
||||
mockStore.brushSettings.size = 20
|
||||
mockStore.brushSettings.hardness = 1
|
||||
mockStore.zoomRatio = 1
|
||||
|
||||
const container = document.createElement('div')
|
||||
vi.spyOn(container, 'getBoundingClientRect').mockReturnValue({
|
||||
left: 30,
|
||||
top: 60,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({})
|
||||
} as DOMRect)
|
||||
|
||||
renderCursor(container)
|
||||
|
||||
// left = 200 + 0 - 20 - 30 = 150; top = 300 + 0 - 20 - 60 = 220
|
||||
const style = styleOf(getBrushEl())
|
||||
expect(style).toContain('left: 150px')
|
||||
expect(style).toContain('top: 220px')
|
||||
})
|
||||
})
|
||||
|
||||
describe('gradient preview', () => {
|
||||
it('should be hidden by default', () => {
|
||||
mockStore.brushPreviewGradientVisible = false
|
||||
renderCursor()
|
||||
expect(styleOf(getGradientEl())).toContain('display: none')
|
||||
})
|
||||
|
||||
it('should be visible when brushPreviewGradientVisible is true', () => {
|
||||
mockStore.brushPreviewGradientVisible = true
|
||||
renderCursor()
|
||||
expect(styleOf(getGradientEl())).toContain('display: block')
|
||||
})
|
||||
|
||||
it('should use a flat fill at hardness=1', () => {
|
||||
mockStore.brushPreviewGradientVisible = true
|
||||
mockStore.brushSettings.hardness = 1
|
||||
mockStore.brushSettings.size = 20
|
||||
renderCursor()
|
||||
|
||||
// hard brush: getEffectiveHardness = (20*1)/20 = 1 → flat color
|
||||
const style = styleOf(getGradientEl())
|
||||
expect(style).toContain('rgba(255, 0, 0, 0.5)')
|
||||
expect(style).not.toContain('radial-gradient')
|
||||
})
|
||||
|
||||
// The radial-gradient (hardness < 1) branch uses a multi-line template
|
||||
// literal as the background value; happy-dom's CSS parser drops the
|
||||
// declaration entirely, so we can't assert on the rendered style. The
|
||||
// underlying math (getEffectiveBrushSize / getEffectiveHardness) is
|
||||
// covered by brushUtils.test.ts.
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
id="maskEditor_brush"
|
||||
data-testid="brush-cursor"
|
||||
:style="{
|
||||
position: 'absolute',
|
||||
opacity: brushOpacity,
|
||||
@@ -15,6 +16,7 @@
|
||||
>
|
||||
<div
|
||||
id="maskEditor_brushPreviewGradient"
|
||||
data-testid="brush-cursor-gradient"
|
||||
:style="{
|
||||
display: gradientVisible ? 'block' : 'none',
|
||||
background: gradientBackground
|
||||
|
||||
230
src/components/maskeditor/BrushSettingsPanel.test.ts
Normal file
230
src/components/maskeditor/BrushSettingsPanel.test.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
/* eslint-disable testing-library/no-container, testing-library/no-node-access -- shape buttons are unlabeled divs and number inputs have no aria labels */
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { reactive } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import BrushSettingsPanel from '@/components/maskeditor/BrushSettingsPanel.vue'
|
||||
import { BrushShape } from '@/extensions/core/maskeditor/types'
|
||||
|
||||
const initialMock = () => ({
|
||||
brushSettings: reactive({
|
||||
type: BrushShape.Arc,
|
||||
size: 10,
|
||||
opacity: 0.7,
|
||||
hardness: 1,
|
||||
stepSize: 5
|
||||
}),
|
||||
rgbColor: '#FF0000',
|
||||
colorInput: null as HTMLInputElement | null,
|
||||
setBrushSize: vi.fn(),
|
||||
setBrushOpacity: vi.fn(),
|
||||
setBrushHardness: vi.fn(),
|
||||
setBrushStepSize: vi.fn(),
|
||||
resetBrushToDefault: vi.fn()
|
||||
})
|
||||
|
||||
let mockStore: ReturnType<typeof initialMock>
|
||||
|
||||
vi.mock('@/stores/maskEditorStore', () => ({
|
||||
useMaskEditorStore: () => mockStore
|
||||
}))
|
||||
|
||||
vi.mock('@/components/maskeditor/controls/SliderControl.vue', () => ({
|
||||
default: {
|
||||
name: 'SliderControlStub',
|
||||
props: ['label', 'min', 'max', 'step', 'modelValue'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `<button data-slider="true" @click="$emit('update:modelValue', 0.5)">{{ modelValue }}</button>`
|
||||
}
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
maskEditor: {
|
||||
brushSettings: 'Brush Settings',
|
||||
brushShape: 'Brush Shape',
|
||||
colorSelector: 'Color Selector',
|
||||
thickness: 'Thickness',
|
||||
opacity: 'Opacity',
|
||||
hardness: 'Hardness',
|
||||
stepSize: 'Step Size',
|
||||
resetToDefault: 'Reset to Default'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const renderPanel = () =>
|
||||
render(BrushSettingsPanel, { global: { plugins: [i18n] } })
|
||||
|
||||
const setNumberInput = (input: HTMLInputElement, value: string): void => {
|
||||
input.value = value
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
}
|
||||
|
||||
describe('BrushSettingsPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockStore = initialMock()
|
||||
})
|
||||
|
||||
describe('brush shape buttons', () => {
|
||||
it('should set brushSettings.type to Arc when arc button clicked', async () => {
|
||||
mockStore.brushSettings.type = BrushShape.Rect
|
||||
const { container } = renderPanel()
|
||||
const user = userEvent.setup()
|
||||
|
||||
const arcEl = container.querySelector(
|
||||
'.maskEditor_sidePanelBrushShapeCircle'
|
||||
)
|
||||
await user.click(arcEl as Element)
|
||||
|
||||
expect(mockStore.brushSettings.type).toBe(BrushShape.Arc)
|
||||
})
|
||||
|
||||
it('should set brushSettings.type to Rect when rect button clicked', async () => {
|
||||
const { container } = renderPanel()
|
||||
const user = userEvent.setup()
|
||||
|
||||
const rectEl = container.querySelector(
|
||||
'.maskEditor_sidePanelBrushShapeSquare'
|
||||
)
|
||||
await user.click(rectEl as Element)
|
||||
|
||||
expect(mockStore.brushSettings.type).toBe(BrushShape.Rect)
|
||||
})
|
||||
})
|
||||
|
||||
describe('reset button', () => {
|
||||
it('should call resetBrushToDefault when clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderPanel()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Reset to Default' }))
|
||||
|
||||
expect(mockStore.resetBrushToDefault).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('numeric inputs', () => {
|
||||
it('should call setBrushSize when size number input changes', () => {
|
||||
const { container } = renderPanel()
|
||||
const sizeInput = container.querySelectorAll(
|
||||
'input[type="number"]'
|
||||
)[0] as HTMLInputElement
|
||||
|
||||
setNumberInput(sizeInput, '50')
|
||||
|
||||
expect(mockStore.setBrushSize).toHaveBeenCalledWith(50)
|
||||
})
|
||||
|
||||
it('should call setBrushOpacity when opacity number input changes', () => {
|
||||
const { container } = renderPanel()
|
||||
const opacityInput = container.querySelectorAll(
|
||||
'input[type="number"]'
|
||||
)[1] as HTMLInputElement
|
||||
|
||||
setNumberInput(opacityInput, '0.4')
|
||||
|
||||
expect(mockStore.setBrushOpacity).toHaveBeenCalledWith(0.4)
|
||||
})
|
||||
|
||||
it('should call setBrushHardness when hardness number input changes', () => {
|
||||
const { container } = renderPanel()
|
||||
const hardnessInput = container.querySelectorAll(
|
||||
'input[type="number"]'
|
||||
)[2] as HTMLInputElement
|
||||
|
||||
setNumberInput(hardnessInput, '0.6')
|
||||
|
||||
expect(mockStore.setBrushHardness).toHaveBeenCalledWith(0.6)
|
||||
})
|
||||
|
||||
it('should call setBrushStepSize when step number input changes', () => {
|
||||
const { container } = renderPanel()
|
||||
const stepInput = container.querySelectorAll(
|
||||
'input[type="number"]'
|
||||
)[3] as HTMLInputElement
|
||||
|
||||
setNumberInput(stepInput, '20')
|
||||
|
||||
expect(mockStore.setBrushStepSize).toHaveBeenCalledWith(20)
|
||||
})
|
||||
})
|
||||
|
||||
describe('size slider (logarithmic)', () => {
|
||||
it('should call setBrushSize with Math.round(Math.pow(250, value))', () => {
|
||||
const { container } = renderPanel()
|
||||
const sizeSlider = container.querySelectorAll(
|
||||
'[data-slider="true"]'
|
||||
)[0] as HTMLElement
|
||||
|
||||
sizeSlider.click()
|
||||
// value = 0.5 → Math.round(Math.pow(250, 0.5)) = 16
|
||||
expect(mockStore.setBrushSize).toHaveBeenCalledWith(16)
|
||||
})
|
||||
|
||||
it('should map size 250 to slider value 1', () => {
|
||||
mockStore.brushSettings.size = 250
|
||||
const { container } = renderPanel()
|
||||
const sizeSlider = container.querySelectorAll(
|
||||
'[data-slider="true"]'
|
||||
)[0] as HTMLElement
|
||||
|
||||
// Math.log(250) / Math.log(250) = 1
|
||||
expect(sizeSlider.textContent).toContain('1')
|
||||
})
|
||||
|
||||
it('should return cached raw slider value when size matches the mapping', async () => {
|
||||
mockStore.setBrushSize.mockImplementation((size: number) => {
|
||||
mockStore.brushSettings.size = size
|
||||
})
|
||||
const { container } = renderPanel()
|
||||
const sizeSlider = container.querySelectorAll(
|
||||
'[data-slider="true"]'
|
||||
)[0] as HTMLElement
|
||||
|
||||
// Click sets rawSliderValue=0.5 → setBrushSize(16) → size=16
|
||||
// → next getter run sees cached match → returns 0.5
|
||||
sizeSlider.click()
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
expect(sizeSlider.textContent).toContain('0.5')
|
||||
})
|
||||
})
|
||||
|
||||
describe('color input', () => {
|
||||
it('should v-model rgbColor on the color input', () => {
|
||||
const { container } = renderPanel()
|
||||
const colorInput = container.querySelector(
|
||||
'input[type="color"]'
|
||||
) as HTMLInputElement
|
||||
|
||||
colorInput.value = '#00ff00'
|
||||
colorInput.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
|
||||
expect(mockStore.rgbColor).toBe('#00ff00')
|
||||
})
|
||||
|
||||
it('should expose color input ref to the store on mount', () => {
|
||||
const { container } = renderPanel()
|
||||
const colorInput = container.querySelector('input[type="color"]')
|
||||
|
||||
expect(mockStore.colorInput).toBe(colorInput)
|
||||
})
|
||||
|
||||
it('should clear store.colorInput on unmount', () => {
|
||||
const { unmount } = renderPanel()
|
||||
expect(mockStore.colorInput).not.toBeNull()
|
||||
|
||||
unmount()
|
||||
|
||||
expect(mockStore.colorInput).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
168
src/components/maskeditor/ColorSelectSettingsPanel.test.ts
Normal file
168
src/components/maskeditor/ColorSelectSettingsPanel.test.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import ColorSelectSettingsPanel from '@/components/maskeditor/ColorSelectSettingsPanel.vue'
|
||||
import { ColorComparisonMethod } from '@/extensions/core/maskeditor/types'
|
||||
|
||||
const mockStore = vi.hoisted(() => ({
|
||||
colorSelectTolerance: 20,
|
||||
selectionOpacity: 100,
|
||||
colorSelectLivePreview: false,
|
||||
applyWholeImage: false,
|
||||
colorComparisonMethod: 'simple' as ColorComparisonMethod,
|
||||
maskBoundary: false,
|
||||
maskTolerance: 0,
|
||||
setColorSelectTolerance: vi.fn(),
|
||||
setSelectionOpacity: vi.fn(),
|
||||
setMaskTolerance: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/maskEditorStore', () => ({
|
||||
useMaskEditorStore: () => mockStore
|
||||
}))
|
||||
|
||||
vi.mock('@/components/maskeditor/controls/SliderControl.vue', () => ({
|
||||
default: {
|
||||
name: 'SliderControlStub',
|
||||
props: ['label', 'min', 'max', 'step', 'modelValue'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `<button data-control="slider" :aria-label="label" @click="$emit('update:modelValue', 99)">{{ modelValue }}</button>`
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/maskeditor/controls/ToggleControl.vue', () => ({
|
||||
default: {
|
||||
name: 'ToggleControlStub',
|
||||
props: ['label', 'modelValue'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `<button data-control="toggle" :aria-label="label" @click="$emit('update:modelValue', !modelValue)">{{ modelValue }}</button>`
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/maskeditor/controls/DropdownControl.vue', () => ({
|
||||
default: {
|
||||
name: 'DropdownControlStub',
|
||||
props: ['label', 'options', 'modelValue'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `<button data-control="dropdown" :aria-label="label" @click="$emit('update:modelValue', 'lab')">{{ modelValue }}</button>`
|
||||
}
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
maskEditor: {
|
||||
colorSelectSettings: 'Color Select Settings',
|
||||
tolerance: 'Tolerance',
|
||||
selectionOpacity: 'Selection Opacity',
|
||||
livePreview: 'Live Preview',
|
||||
applyToWholeImage: 'Apply to Whole Image',
|
||||
method: 'Method',
|
||||
stopAtMask: 'Stop at mask',
|
||||
maskTolerance: 'Mask Tolerance'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const renderPanel = () =>
|
||||
render(ColorSelectSettingsPanel, { global: { plugins: [i18n] } })
|
||||
|
||||
describe('ColorSelectSettingsPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockStore.colorSelectTolerance = 20
|
||||
mockStore.selectionOpacity = 100
|
||||
mockStore.colorSelectLivePreview = false
|
||||
mockStore.applyWholeImage = false
|
||||
mockStore.colorComparisonMethod = ColorComparisonMethod.Simple
|
||||
mockStore.maskBoundary = false
|
||||
mockStore.maskTolerance = 0
|
||||
})
|
||||
|
||||
it('should call setColorSelectTolerance when tolerance slider emits', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderPanel()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Tolerance' }))
|
||||
|
||||
expect(mockStore.setColorSelectTolerance).toHaveBeenCalledWith(99)
|
||||
})
|
||||
|
||||
it('should call setSelectionOpacity when selection opacity slider emits', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderPanel()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Selection Opacity' }))
|
||||
|
||||
expect(mockStore.setSelectionOpacity).toHaveBeenCalledWith(99)
|
||||
})
|
||||
|
||||
it('should toggle colorSelectLivePreview when live preview toggle emits', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockStore.colorSelectLivePreview = false
|
||||
renderPanel()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Live Preview' }))
|
||||
|
||||
expect(mockStore.colorSelectLivePreview).toBe(true)
|
||||
})
|
||||
|
||||
it('should toggle applyWholeImage when whole-image toggle emits', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockStore.applyWholeImage = false
|
||||
renderPanel()
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Apply to Whole Image' })
|
||||
)
|
||||
|
||||
expect(mockStore.applyWholeImage).toBe(true)
|
||||
})
|
||||
|
||||
it('should set comparison method when dropdown emits', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderPanel()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Method' }))
|
||||
|
||||
expect(mockStore.colorComparisonMethod).toBe(ColorComparisonMethod.LAB)
|
||||
})
|
||||
|
||||
it('should toggle maskBoundary when stop-at-mask toggle emits', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockStore.maskBoundary = false
|
||||
renderPanel()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Stop at mask' }))
|
||||
|
||||
expect(mockStore.maskBoundary).toBe(true)
|
||||
})
|
||||
|
||||
it('should call setMaskTolerance when mask tolerance slider emits', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderPanel()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Mask Tolerance' }))
|
||||
|
||||
expect(mockStore.setMaskTolerance).toHaveBeenCalledWith(99)
|
||||
})
|
||||
|
||||
it('should reflect store values on the controls', () => {
|
||||
mockStore.colorSelectTolerance = 77
|
||||
mockStore.colorComparisonMethod = ColorComparisonMethod.HSL
|
||||
|
||||
renderPanel()
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Tolerance' }).textContent
|
||||
).toContain('77')
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Method' }).textContent
|
||||
).toContain('hsl')
|
||||
})
|
||||
})
|
||||
281
src/components/maskeditor/ImageLayerSettingsPanel.test.ts
Normal file
281
src/components/maskeditor/ImageLayerSettingsPanel.test.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
/* eslint-disable testing-library/no-container, testing-library/no-node-access -- layer rows have unlabeled checkboxes and the blend-mode select has no role-friendly label */
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { reactive } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { useToolManager } from '@/composables/maskeditor/useToolManager'
|
||||
import ImageLayerSettingsPanel from '@/components/maskeditor/ImageLayerSettingsPanel.vue'
|
||||
import { MaskBlendMode, Tools } from '@/extensions/core/maskeditor/types'
|
||||
|
||||
type ToolManager = ReturnType<typeof useToolManager>
|
||||
|
||||
const initialMock = () =>
|
||||
reactive({
|
||||
maskOpacity: 0.8,
|
||||
maskBlendMode: MaskBlendMode.Black,
|
||||
activeLayer: 'mask' as 'mask' | 'rgb',
|
||||
currentTool: Tools.MaskPen,
|
||||
image: { src: 'https://example.com/base.png' } as { src: string } | null,
|
||||
maskCanvas: null as HTMLCanvasElement | null,
|
||||
rgbCanvas: null as HTMLCanvasElement | null,
|
||||
imgCanvas: null as HTMLCanvasElement | null,
|
||||
setMaskOpacity: vi.fn()
|
||||
})
|
||||
|
||||
let mockStore: ReturnType<typeof initialMock>
|
||||
const mockUpdateMaskColor = vi.fn().mockResolvedValue(undefined)
|
||||
const mockSetActiveLayer = vi.fn()
|
||||
|
||||
vi.mock('@/stores/maskEditorStore', () => ({
|
||||
useMaskEditorStore: () => mockStore
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/maskeditor/useCanvasManager', () => ({
|
||||
useCanvasManager: () => ({ updateMaskColor: mockUpdateMaskColor })
|
||||
}))
|
||||
|
||||
vi.mock('@/components/maskeditor/controls/SliderControl.vue', () => ({
|
||||
default: {
|
||||
name: 'SliderControlStub',
|
||||
props: ['label', 'min', 'max', 'step', 'modelValue'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `<button data-slider="true" @click="$emit('update:modelValue', 0.3)">{{ modelValue }}</button>`
|
||||
}
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
maskEditor: {
|
||||
layers: 'Layers',
|
||||
maskOpacity: 'Mask Opacity',
|
||||
maskBlendingOptions: 'Mask Blending Options',
|
||||
black: 'Black',
|
||||
white: 'White',
|
||||
negative: 'Negative',
|
||||
maskLayer: 'Mask Layer',
|
||||
paintLayer: 'Paint Layer',
|
||||
baseImageLayer: 'Base Image Layer',
|
||||
activateLayer: 'Activate Layer',
|
||||
baseLayerPreview: 'Base layer preview'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const renderPanel = (props?: Record<string, unknown>) =>
|
||||
render(ImageLayerSettingsPanel, {
|
||||
global: { plugins: [i18n] },
|
||||
props
|
||||
})
|
||||
|
||||
const makeCanvas = (): HTMLCanvasElement => document.createElement('canvas')
|
||||
|
||||
describe('ImageLayerSettingsPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockStore = initialMock()
|
||||
})
|
||||
|
||||
describe('mask opacity slider', () => {
|
||||
it('should call setMaskOpacity and update mask canvas opacity', async () => {
|
||||
const user = userEvent.setup()
|
||||
const canvas = makeCanvas()
|
||||
mockStore.maskCanvas = canvas
|
||||
const { container } = renderPanel()
|
||||
|
||||
await user.click(
|
||||
container.querySelector('[data-slider="true"]') as HTMLElement
|
||||
)
|
||||
|
||||
expect(mockStore.setMaskOpacity).toHaveBeenCalledWith(0.3)
|
||||
expect(canvas.style.opacity).toBe('0.3')
|
||||
})
|
||||
|
||||
it('should leave canvas alone when no maskCanvas is set', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { container } = renderPanel()
|
||||
|
||||
await expect(
|
||||
user.click(
|
||||
container.querySelector('[data-slider="true"]') as HTMLElement
|
||||
)
|
||||
).resolves.not.toThrow()
|
||||
|
||||
expect(mockStore.setMaskOpacity).toHaveBeenCalledWith(0.3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('blend mode select', () => {
|
||||
it.each([
|
||||
['black', MaskBlendMode.Black],
|
||||
['white', MaskBlendMode.White],
|
||||
['negative', MaskBlendMode.Negative],
|
||||
['unknown-fallback', MaskBlendMode.Black]
|
||||
] as const)('should map %s to MaskBlendMode.%s', async (raw, expected) => {
|
||||
const { container } = renderPanel()
|
||||
const select = container.querySelector('select') as HTMLSelectElement
|
||||
|
||||
Object.defineProperty(select, 'value', {
|
||||
value: raw,
|
||||
configurable: true
|
||||
})
|
||||
select.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
|
||||
expect(mockStore.maskBlendMode).toBe(expected)
|
||||
expect(mockUpdateMaskColor).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('layer visibility checkboxes', () => {
|
||||
it('should toggle mask canvas opacity to maskOpacity when checked, 0 when unchecked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const canvas = makeCanvas()
|
||||
mockStore.maskCanvas = canvas
|
||||
mockStore.maskOpacity = 0.5
|
||||
|
||||
const { container } = renderPanel()
|
||||
const checkbox = container.querySelectorAll(
|
||||
'input[type="checkbox"]'
|
||||
)[0] as HTMLInputElement
|
||||
|
||||
await user.click(checkbox)
|
||||
expect(canvas.style.opacity).toBe('0')
|
||||
|
||||
await user.click(checkbox)
|
||||
expect(canvas.style.opacity).toBe('0.5')
|
||||
})
|
||||
|
||||
it('should toggle paint (rgb) canvas opacity between 0 and 1', async () => {
|
||||
const user = userEvent.setup()
|
||||
const canvas = makeCanvas()
|
||||
mockStore.rgbCanvas = canvas
|
||||
|
||||
const { container } = renderPanel()
|
||||
const checkbox = container.querySelectorAll(
|
||||
'input[type="checkbox"]'
|
||||
)[1] as HTMLInputElement
|
||||
|
||||
await user.click(checkbox)
|
||||
expect(canvas.style.opacity).toBe('0')
|
||||
|
||||
await user.click(checkbox)
|
||||
expect(canvas.style.opacity).toBe('1')
|
||||
})
|
||||
|
||||
it('should toggle base image canvas opacity between 0 and 1', async () => {
|
||||
const user = userEvent.setup()
|
||||
const canvas = makeCanvas()
|
||||
mockStore.imgCanvas = canvas
|
||||
|
||||
const { container } = renderPanel()
|
||||
const checkbox = container.querySelectorAll(
|
||||
'input[type="checkbox"]'
|
||||
)[2] as HTMLInputElement
|
||||
|
||||
await user.click(checkbox)
|
||||
expect(canvas.style.opacity).toBe('0')
|
||||
})
|
||||
|
||||
it('should not throw when toggling visibility for missing canvases', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { container } = renderPanel()
|
||||
const checkboxes = container.querySelectorAll('input[type="checkbox"]')
|
||||
|
||||
for (const cb of checkboxes) {
|
||||
await expect(user.click(cb as HTMLInputElement)).resolves.not.toThrow()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('activate layer buttons', () => {
|
||||
it('should forward the layer to toolManager.setActiveLayer when clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockStore.activeLayer = 'rgb'
|
||||
|
||||
renderPanel({
|
||||
toolManager: {
|
||||
setActiveLayer: mockSetActiveLayer
|
||||
} as unknown as ToolManager
|
||||
})
|
||||
|
||||
const [maskBtn] = screen.getAllByRole('button', {
|
||||
name: 'Activate Layer'
|
||||
})
|
||||
await user.click(maskBtn)
|
||||
|
||||
expect(mockSetActiveLayer).toHaveBeenCalledWith('mask')
|
||||
})
|
||||
|
||||
it('should not throw when toolManager prop is omitted', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockStore.activeLayer = 'rgb'
|
||||
|
||||
renderPanel()
|
||||
const [maskBtn] = screen.getAllByRole('button', {
|
||||
name: 'Activate Layer'
|
||||
})
|
||||
|
||||
await expect(user.click(maskBtn)).resolves.not.toThrow()
|
||||
})
|
||||
|
||||
it('should mark the active-layer button disabled', () => {
|
||||
mockStore.activeLayer = 'mask'
|
||||
|
||||
renderPanel()
|
||||
const [maskBtn] = screen.getAllByRole('button', {
|
||||
name: 'Activate Layer'
|
||||
})
|
||||
|
||||
expect(maskBtn.hasAttribute('disabled')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('paint layer activate visibility', () => {
|
||||
const styleOf = (el: Element): string => el.getAttribute('style') ?? ''
|
||||
|
||||
it('should hide paint activate button when current tool is not Eraser', () => {
|
||||
mockStore.currentTool = Tools.MaskPen
|
||||
const { container } = renderPanel()
|
||||
const buttons = Array.from(container.querySelectorAll('button'))
|
||||
const paintBtn = buttons.find((b) => styleOf(b).includes('display:'))
|
||||
|
||||
expect(paintBtn).toBeDefined()
|
||||
expect(styleOf(paintBtn as Element)).toContain('display: none')
|
||||
})
|
||||
|
||||
it('should show paint activate button when current tool is Eraser', () => {
|
||||
mockStore.currentTool = Tools.Eraser
|
||||
const { container } = renderPanel()
|
||||
const buttons = Array.from(container.querySelectorAll('button'))
|
||||
const paintBtn = buttons.find((b) => styleOf(b).includes('display:'))
|
||||
|
||||
expect(paintBtn).toBeDefined()
|
||||
expect(styleOf(paintBtn as Element)).toContain('display: block')
|
||||
})
|
||||
})
|
||||
|
||||
describe('base image preview', () => {
|
||||
it('should render base image src from store', () => {
|
||||
mockStore.image = { src: 'https://example.com/img.png' }
|
||||
renderPanel()
|
||||
const img = screen.getByAltText('Base layer preview')
|
||||
|
||||
expect((img as HTMLImageElement).src).toBe('https://example.com/img.png')
|
||||
})
|
||||
|
||||
it('should render empty src when no image', () => {
|
||||
mockStore.image = null
|
||||
renderPanel()
|
||||
const img = screen.getByAltText('Base layer preview')
|
||||
|
||||
expect(img.getAttribute('src')).toBe('')
|
||||
})
|
||||
})
|
||||
})
|
||||
87
src/components/maskeditor/PaintBucketSettingsPanel.test.ts
Normal file
87
src/components/maskeditor/PaintBucketSettingsPanel.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import PaintBucketSettingsPanel from '@/components/maskeditor/PaintBucketSettingsPanel.vue'
|
||||
|
||||
const mockStore = vi.hoisted(() => ({
|
||||
paintBucketTolerance: 5,
|
||||
fillOpacity: 100,
|
||||
setPaintBucketTolerance: vi.fn(),
|
||||
setFillOpacity: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/maskEditorStore', () => ({
|
||||
useMaskEditorStore: () => mockStore
|
||||
}))
|
||||
|
||||
vi.mock('@/components/maskeditor/controls/SliderControl.vue', () => ({
|
||||
default: {
|
||||
name: 'SliderControlStub',
|
||||
props: ['label', 'min', 'max', 'step', 'modelValue'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `<button :aria-label="label" @click="$emit('update:modelValue', 42)">{{ modelValue }}</button>`
|
||||
}
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
maskEditor: {
|
||||
paintBucketSettings: 'Paint Bucket Settings',
|
||||
tolerance: 'Tolerance',
|
||||
fillOpacity: 'Fill Opacity'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const renderPanel = () =>
|
||||
render(PaintBucketSettingsPanel, { global: { plugins: [i18n] } })
|
||||
|
||||
describe('PaintBucketSettingsPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockStore.paintBucketTolerance = 5
|
||||
mockStore.fillOpacity = 100
|
||||
})
|
||||
|
||||
it('should bind tolerance slider to store value', () => {
|
||||
mockStore.paintBucketTolerance = 87
|
||||
renderPanel()
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Tolerance' }).textContent
|
||||
).toContain('87')
|
||||
})
|
||||
|
||||
it('should bind fill opacity slider to store value', () => {
|
||||
mockStore.fillOpacity = 33
|
||||
renderPanel()
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Fill Opacity' }).textContent
|
||||
).toContain('33')
|
||||
})
|
||||
|
||||
it('should call setPaintBucketTolerance when tolerance slider emits', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderPanel()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Tolerance' }))
|
||||
|
||||
expect(mockStore.setPaintBucketTolerance).toHaveBeenCalledWith(42)
|
||||
})
|
||||
|
||||
it('should call setFillOpacity when fill opacity slider emits', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderPanel()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Fill Opacity' }))
|
||||
|
||||
expect(mockStore.setFillOpacity).toHaveBeenCalledWith(42)
|
||||
})
|
||||
})
|
||||
184
src/components/maskeditor/PointerZone.test.ts
Normal file
184
src/components/maskeditor/PointerZone.test.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { reactive, nextTick } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { usePanAndZoom } from '@/composables/maskeditor/usePanAndZoom'
|
||||
import type { useToolManager } from '@/composables/maskeditor/useToolManager'
|
||||
|
||||
import PointerZone from '@/components/maskeditor/PointerZone.vue'
|
||||
|
||||
type ToolManager = ReturnType<typeof useToolManager>
|
||||
type PanZoom = ReturnType<typeof usePanAndZoom>
|
||||
|
||||
const initialMock = () =>
|
||||
reactive({
|
||||
pointerZone: null as HTMLElement | null,
|
||||
isPanning: false,
|
||||
brushVisible: true
|
||||
})
|
||||
|
||||
let mockStore: ReturnType<typeof initialMock>
|
||||
|
||||
const mockToolManager = vi.hoisted(() => ({
|
||||
handlePointerDown: vi.fn().mockResolvedValue(undefined),
|
||||
handlePointerMove: vi.fn().mockResolvedValue(undefined),
|
||||
handlePointerUp: vi.fn().mockResolvedValue(undefined),
|
||||
updateCursor: vi.fn()
|
||||
}))
|
||||
|
||||
const mockPanZoom = vi.hoisted(() => ({
|
||||
handleTouchStart: vi.fn(),
|
||||
handleTouchMove: vi.fn().mockResolvedValue(undefined),
|
||||
handleTouchEnd: vi.fn(),
|
||||
zoom: vi.fn().mockResolvedValue(undefined),
|
||||
updateCursorPosition: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/maskEditorStore', () => ({
|
||||
useMaskEditorStore: () => mockStore
|
||||
}))
|
||||
|
||||
const renderZone = () =>
|
||||
render(PointerZone, {
|
||||
props: {
|
||||
toolManager: mockToolManager as unknown as ToolManager,
|
||||
panZoom: mockPanZoom as unknown as PanZoom
|
||||
}
|
||||
})
|
||||
|
||||
const getZone = (): HTMLDivElement =>
|
||||
screen.getByTestId('pointer-zone') as HTMLDivElement
|
||||
|
||||
describe('PointerZone', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockStore = initialMock()
|
||||
})
|
||||
|
||||
describe('mount', () => {
|
||||
it('should expose its root element to the store on mount', () => {
|
||||
renderZone()
|
||||
expect(mockStore.pointerZone).toBe(getZone())
|
||||
})
|
||||
})
|
||||
|
||||
describe('pointer event forwarding', () => {
|
||||
it.each([
|
||||
['pointerdown', 'handlePointerDown'],
|
||||
['pointermove', 'handlePointerMove'],
|
||||
['pointerup', 'handlePointerUp']
|
||||
] as const)(
|
||||
'should forward %s to toolManager.%s',
|
||||
async (eventName, handlerName) => {
|
||||
renderZone()
|
||||
const zone = getZone()
|
||||
|
||||
zone.dispatchEvent(new Event(eventName, { bubbles: true }))
|
||||
await nextTick()
|
||||
|
||||
expect(mockToolManager[handlerName]).toHaveBeenCalledTimes(1)
|
||||
}
|
||||
)
|
||||
|
||||
it('should hide brush and clear cursor on pointerleave', () => {
|
||||
renderZone()
|
||||
const zone = getZone()
|
||||
zone.style.cursor = 'crosshair'
|
||||
mockStore.brushVisible = true
|
||||
|
||||
zone.dispatchEvent(new Event('pointerleave', { bubbles: true }))
|
||||
|
||||
expect(mockStore.brushVisible).toBe(false)
|
||||
expect(zone.style.cursor).toBe('')
|
||||
})
|
||||
|
||||
it('should call toolManager.updateCursor on pointerenter', () => {
|
||||
renderZone()
|
||||
const zone = getZone()
|
||||
|
||||
zone.dispatchEvent(new Event('pointerenter', { bubbles: true }))
|
||||
|
||||
expect(mockToolManager.updateCursor).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('touch event forwarding', () => {
|
||||
it.each([
|
||||
['touchstart', 'handleTouchStart'],
|
||||
['touchmove', 'handleTouchMove'],
|
||||
['touchend', 'handleTouchEnd']
|
||||
] as const)(
|
||||
'should forward %s to panZoom.%s',
|
||||
async (eventName, handlerName) => {
|
||||
renderZone()
|
||||
const zone = getZone()
|
||||
|
||||
zone.dispatchEvent(new Event(eventName, { bubbles: true }))
|
||||
await nextTick()
|
||||
|
||||
expect(mockPanZoom[handlerName]).toHaveBeenCalledTimes(1)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('wheel handling', () => {
|
||||
it('should call panZoom.zoom and update cursor position with the wheel coords', async () => {
|
||||
renderZone()
|
||||
const zone = getZone()
|
||||
|
||||
const event = new WheelEvent('wheel', { bubbles: true, deltaY: -1 })
|
||||
// happy-dom doesn't propagate clientX/clientY through the WheelEvent
|
||||
// constructor, so set them directly on the event instance.
|
||||
Object.defineProperty(event, 'clientX', { value: 123 })
|
||||
Object.defineProperty(event, 'clientY', { value: 45 })
|
||||
zone.dispatchEvent(event)
|
||||
// Flush awaited zoom() then the follow-up updateCursorPosition call
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
expect(mockPanZoom.zoom).toHaveBeenCalledTimes(1)
|
||||
expect(mockPanZoom.updateCursorPosition).toHaveBeenCalledWith({
|
||||
x: 123,
|
||||
y: 45
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('isPanning watcher', () => {
|
||||
it('should set cursor to "grabbing" when panning starts', async () => {
|
||||
renderZone()
|
||||
const zone = getZone()
|
||||
|
||||
mockStore.isPanning = true
|
||||
await nextTick()
|
||||
|
||||
expect(zone.style.cursor).toBe('grabbing')
|
||||
})
|
||||
|
||||
it('should call toolManager.updateCursor when panning ends', async () => {
|
||||
renderZone()
|
||||
|
||||
mockStore.isPanning = true
|
||||
await nextTick()
|
||||
mockToolManager.updateCursor.mockClear()
|
||||
mockStore.isPanning = false
|
||||
await nextTick()
|
||||
|
||||
expect(mockToolManager.updateCursor).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('contextmenu', () => {
|
||||
it('should prevent default on contextmenu', () => {
|
||||
renderZone()
|
||||
const zone = getZone()
|
||||
|
||||
const event = new Event('contextmenu', {
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
zone.dispatchEvent(event)
|
||||
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
ref="pointerZoneRef"
|
||||
data-testid="pointer-zone"
|
||||
class="h-full w-[calc(100%-4rem-220px)]"
|
||||
@pointerdown="handlePointerDown"
|
||||
@pointermove="handlePointerMove"
|
||||
|
||||
70
src/components/maskeditor/SettingsPanelContainer.test.ts
Normal file
70
src/components/maskeditor/SettingsPanelContainer.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { render } from '@testing-library/vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import SettingsPanelContainer from '@/components/maskeditor/SettingsPanelContainer.vue'
|
||||
import { Tools } from '@/extensions/core/maskeditor/types'
|
||||
|
||||
const mockStore = vi.hoisted(() => ({
|
||||
currentTool: 'pen' as Tools
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/maskEditorStore', () => ({
|
||||
useMaskEditorStore: () => mockStore
|
||||
}))
|
||||
|
||||
vi.mock('@/components/maskeditor/BrushSettingsPanel.vue', () => ({
|
||||
default: {
|
||||
name: 'BrushSettingsPanelStub',
|
||||
template: '<div>brush-panel</div>'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/maskeditor/ColorSelectSettingsPanel.vue', () => ({
|
||||
default: {
|
||||
name: 'ColorSelectSettingsPanelStub',
|
||||
template: '<div>color-panel</div>'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/maskeditor/PaintBucketSettingsPanel.vue', () => ({
|
||||
default: {
|
||||
name: 'PaintBucketSettingsPanelStub',
|
||||
template: '<div>bucket-panel</div>'
|
||||
}
|
||||
}))
|
||||
|
||||
describe('SettingsPanelContainer', () => {
|
||||
beforeEach(() => {
|
||||
mockStore.currentTool = Tools.MaskPen
|
||||
})
|
||||
|
||||
it('should render PaintBucketSettingsPanel when current tool is MaskBucket', () => {
|
||||
mockStore.currentTool = Tools.MaskBucket
|
||||
const { container } = render(SettingsPanelContainer)
|
||||
expect(container.textContent).toContain('bucket-panel')
|
||||
})
|
||||
|
||||
it('should render ColorSelectSettingsPanel when current tool is MaskColorFill', () => {
|
||||
mockStore.currentTool = Tools.MaskColorFill
|
||||
const { container } = render(SettingsPanelContainer)
|
||||
expect(container.textContent).toContain('color-panel')
|
||||
})
|
||||
|
||||
it('should render BrushSettingsPanel for any other tool', () => {
|
||||
mockStore.currentTool = Tools.MaskPen
|
||||
const { container } = render(SettingsPanelContainer)
|
||||
expect(container.textContent).toContain('brush-panel')
|
||||
})
|
||||
|
||||
it('should render BrushSettingsPanel for Eraser', () => {
|
||||
mockStore.currentTool = Tools.Eraser
|
||||
const { container } = render(SettingsPanelContainer)
|
||||
expect(container.textContent).toContain('brush-panel')
|
||||
})
|
||||
|
||||
it('should render BrushSettingsPanel for PaintPen', () => {
|
||||
mockStore.currentTool = Tools.PaintPen
|
||||
const { container } = render(SettingsPanelContainer)
|
||||
expect(container.textContent).toContain('brush-panel')
|
||||
})
|
||||
})
|
||||
49
src/components/maskeditor/SidePanel.test.ts
Normal file
49
src/components/maskeditor/SidePanel.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { useToolManager } from '@/composables/maskeditor/useToolManager'
|
||||
|
||||
import SidePanel from '@/components/maskeditor/SidePanel.vue'
|
||||
|
||||
type ToolManager = ReturnType<typeof useToolManager>
|
||||
|
||||
vi.mock('@/components/maskeditor/SettingsPanelContainer.vue', () => ({
|
||||
default: {
|
||||
name: 'SettingsPanelContainerStub',
|
||||
template: '<div data-testid="settings-panel-stub">settings</div>'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/maskeditor/ImageLayerSettingsPanel.vue', () => ({
|
||||
default: {
|
||||
name: 'ImageLayerSettingsPanelStub',
|
||||
props: ['toolManager'],
|
||||
template:
|
||||
'<div data-testid="image-layer-stub">image-layer:{{ toolManager?.tag ?? "none" }}</div>'
|
||||
}
|
||||
}))
|
||||
|
||||
describe('SidePanel', () => {
|
||||
it('should render both child panels', () => {
|
||||
render(SidePanel)
|
||||
|
||||
expect(screen.getByTestId('settings-panel-stub')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('image-layer-stub')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should forward toolManager prop to ImageLayerSettingsPanel', () => {
|
||||
const toolManager = { tag: 'my-tool-manager' } as unknown as ToolManager
|
||||
|
||||
render(SidePanel, { props: { toolManager } })
|
||||
|
||||
expect(screen.getByTestId('image-layer-stub').textContent).toContain(
|
||||
'my-tool-manager'
|
||||
)
|
||||
})
|
||||
|
||||
it('should render with no toolManager passed through', () => {
|
||||
render(SidePanel)
|
||||
|
||||
expect(screen.getByTestId('image-layer-stub').textContent).toContain('none')
|
||||
})
|
||||
})
|
||||
155
src/components/maskeditor/ToolPanel.test.ts
Normal file
155
src/components/maskeditor/ToolPanel.test.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { reactive } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { useToolManager } from '@/composables/maskeditor/useToolManager'
|
||||
|
||||
import ToolPanel from '@/components/maskeditor/ToolPanel.vue'
|
||||
import { Tools, allTools } from '@/extensions/core/maskeditor/types'
|
||||
|
||||
type ToolManager = ReturnType<typeof useToolManager>
|
||||
|
||||
vi.mock('@/extensions/core/maskeditor/constants', () => ({
|
||||
iconsHtml: {
|
||||
pen: '<svg data-testid="icon-pen" />',
|
||||
rgbPaint: '<svg data-testid="icon-rgbPaint" />',
|
||||
eraser: '<svg data-testid="icon-eraser" />',
|
||||
paintBucket: '<svg data-testid="icon-paintBucket" />',
|
||||
colorSelect: '<svg data-testid="icon-colorSelect" />'
|
||||
}
|
||||
}))
|
||||
|
||||
const initialMock = () =>
|
||||
reactive({
|
||||
currentTool: Tools.MaskPen as Tools,
|
||||
displayZoomRatio: 1,
|
||||
image: null as { width: number; height: number } | null,
|
||||
resetZoom: vi.fn()
|
||||
})
|
||||
|
||||
let mockStore: ReturnType<typeof initialMock>
|
||||
|
||||
vi.mock('@/stores/maskEditorStore', () => ({
|
||||
useMaskEditorStore: () => mockStore
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
maskEditor: {
|
||||
clickToResetZoom: 'Click to reset zoom'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const mockToolManager = vi.hoisted(() => ({
|
||||
switchTool: vi.fn()
|
||||
}))
|
||||
|
||||
const renderPanel = () =>
|
||||
render(ToolPanel, {
|
||||
global: { plugins: [i18n] },
|
||||
props: { toolManager: mockToolManager as unknown as ToolManager }
|
||||
})
|
||||
|
||||
const getToolButton = (tool: Tools): HTMLElement => {
|
||||
const btns = screen.getAllByTestId('tool-button')
|
||||
const match = btns.find((b) => b.dataset.tool === tool)
|
||||
if (!match) throw new Error(`tool button for "${tool}" not found`)
|
||||
return match
|
||||
}
|
||||
|
||||
describe('ToolPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockStore = initialMock()
|
||||
})
|
||||
|
||||
describe('tool list rendering', () => {
|
||||
it('should render one button per tool in allTools', () => {
|
||||
renderPanel()
|
||||
expect(screen.getAllByTestId('tool-button')).toHaveLength(allTools.length)
|
||||
})
|
||||
|
||||
it('should render the icon HTML for each tool', () => {
|
||||
renderPanel()
|
||||
for (const tool of allTools) {
|
||||
expect(screen.getByTestId(`icon-${tool}`)).toBeInTheDocument()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('current tool highlight', () => {
|
||||
it.each([Tools.MaskPen, Tools.Eraser, Tools.PaintPen] as const)(
|
||||
'should mark the %s button as selected when it is the current tool',
|
||||
(tool) => {
|
||||
mockStore.currentTool = tool
|
||||
renderPanel()
|
||||
|
||||
expect(getToolButton(tool).className).toContain(
|
||||
'maskEditor_toolPanelContainerSelected'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('should not mark non-current tools as selected', () => {
|
||||
mockStore.currentTool = Tools.MaskPen
|
||||
renderPanel()
|
||||
|
||||
for (const tool of allTools) {
|
||||
if (tool === Tools.MaskPen) continue
|
||||
expect(getToolButton(tool).className).not.toContain(
|
||||
'maskEditor_toolPanelContainerSelected'
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('tool selection', () => {
|
||||
it('should call toolManager.switchTool with the clicked tool', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderPanel()
|
||||
|
||||
await user.click(getToolButton(Tools.Eraser))
|
||||
|
||||
expect(mockToolManager.switchTool).toHaveBeenCalledWith(Tools.Eraser)
|
||||
})
|
||||
})
|
||||
|
||||
describe('zoom indicator', () => {
|
||||
it('should render rounded zoom percentage from displayZoomRatio', () => {
|
||||
mockStore.displayZoomRatio = 1.236
|
||||
renderPanel()
|
||||
|
||||
expect(screen.getByTestId('zoom-percentage').textContent).toBe('124%')
|
||||
})
|
||||
|
||||
it('should render image dimensions when an image is loaded', () => {
|
||||
mockStore.image = { width: 800, height: 600 }
|
||||
renderPanel()
|
||||
|
||||
expect(screen.getByTestId('zoom-dimensions').textContent).toBe('800x600')
|
||||
})
|
||||
|
||||
it('should render a single-space placeholder when no image', () => {
|
||||
mockStore.image = null
|
||||
renderPanel()
|
||||
|
||||
expect(screen.getByTestId('zoom-dimensions').textContent).toBe(' ')
|
||||
})
|
||||
|
||||
it('should call resetZoom when the zoom indicator is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderPanel()
|
||||
|
||||
await user.click(screen.getByTestId('zoom-percentage'))
|
||||
|
||||
expect(mockStore.resetZoom).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -4,6 +4,8 @@
|
||||
<div
|
||||
v-for="tool in allTools"
|
||||
:key="tool"
|
||||
data-testid="tool-button"
|
||||
:data-tool="tool"
|
||||
:class="[
|
||||
'maskEditor_toolPanelContainer hover:bg-secondary-background-hover',
|
||||
{ maskEditor_toolPanelContainerSelected: currentTool === tool }
|
||||
@@ -23,8 +25,12 @@
|
||||
:title="t('maskEditor.clickToResetZoom')"
|
||||
@click="onResetZoom"
|
||||
>
|
||||
<span class="text-sm text-text-secondary">{{ zoomText }}</span>
|
||||
<span class="text-xs text-text-secondary">{{ dimensionsText }}</span>
|
||||
<span data-testid="zoom-percentage" class="text-sm text-text-secondary">{{
|
||||
zoomText
|
||||
}}</span>
|
||||
<span data-testid="zoom-dimensions" class="text-xs text-text-secondary">{{
|
||||
dimensionsText
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
79
src/components/maskeditor/controls/DropdownControl.test.ts
Normal file
79
src/components/maskeditor/controls/DropdownControl.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import DropdownControl from './DropdownControl.vue'
|
||||
|
||||
const renderComponent = (
|
||||
props: {
|
||||
label?: string
|
||||
options?: string[] | { label: string; value: string | number }[]
|
||||
modelValue?: string | number
|
||||
} = {},
|
||||
onUpdate?: (value: string | number) => void
|
||||
) => {
|
||||
const user = userEvent.setup()
|
||||
const utils = render(DropdownControl, {
|
||||
props: {
|
||||
label: 'Mode',
|
||||
options: ['One', 'Two', 'Three'],
|
||||
modelValue: 'One',
|
||||
...props,
|
||||
'onUpdate:modelValue': onUpdate
|
||||
}
|
||||
})
|
||||
return { user, ...utils }
|
||||
}
|
||||
|
||||
describe('DropdownControl', () => {
|
||||
it('should render the label', () => {
|
||||
renderComponent({ label: 'Brush Mode' })
|
||||
expect(screen.getByText('Brush Mode')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should expand string options to {label,value} pairs', () => {
|
||||
renderComponent({ options: ['Alpha', 'Beta'], modelValue: 'Alpha' })
|
||||
|
||||
const select = screen.getByRole('combobox') as HTMLSelectElement
|
||||
const values = Array.from(select.options).map((o) => o.value)
|
||||
const labels = Array.from(select.options).map((o) => o.textContent?.trim())
|
||||
|
||||
expect(values).toEqual(['Alpha', 'Beta'])
|
||||
expect(labels).toEqual(['Alpha', 'Beta'])
|
||||
})
|
||||
|
||||
it('should preserve {label,value} options as-is', () => {
|
||||
renderComponent({
|
||||
options: [
|
||||
{ label: 'High', value: 1 },
|
||||
{ label: 'Low', value: 2 }
|
||||
],
|
||||
modelValue: 1
|
||||
})
|
||||
|
||||
const select = screen.getByRole('combobox') as HTMLSelectElement
|
||||
expect(Array.from(select.options).map((o) => o.value)).toEqual(['1', '2'])
|
||||
expect(
|
||||
Array.from(select.options).map((o) => o.textContent?.trim())
|
||||
).toEqual(['High', 'Low'])
|
||||
})
|
||||
|
||||
it('should reflect modelValue as the selected option', () => {
|
||||
renderComponent({ options: ['One', 'Two'], modelValue: 'Two' })
|
||||
expect((screen.getByRole('combobox') as HTMLSelectElement).value).toBe(
|
||||
'Two'
|
||||
)
|
||||
})
|
||||
|
||||
it('should emit update:modelValue with the chosen string value', async () => {
|
||||
const onUpdate = vi.fn()
|
||||
const { user } = renderComponent(
|
||||
{ options: ['One', 'Two', 'Three'], modelValue: 'One' },
|
||||
onUpdate
|
||||
)
|
||||
|
||||
await user.selectOptions(screen.getByRole('combobox'), 'Three')
|
||||
|
||||
expect(onUpdate).toHaveBeenCalledWith('Three')
|
||||
})
|
||||
})
|
||||
64
src/components/maskeditor/controls/SliderControl.test.ts
Normal file
64
src/components/maskeditor/controls/SliderControl.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import SliderControl from './SliderControl.vue'
|
||||
|
||||
const renderComponent = (
|
||||
props: {
|
||||
label?: string
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
modelValue?: number
|
||||
} = {},
|
||||
onUpdate?: (value: number) => void
|
||||
) => {
|
||||
return render(SliderControl, {
|
||||
props: {
|
||||
label: 'Brush Size',
|
||||
min: 1,
|
||||
max: 100,
|
||||
modelValue: 10,
|
||||
...props,
|
||||
'onUpdate:modelValue': onUpdate
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const setSliderValue = (input: HTMLInputElement, value: string): void => {
|
||||
input.value = value
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
}
|
||||
|
||||
describe('SliderControl', () => {
|
||||
it('should render the label', () => {
|
||||
renderComponent({ label: 'Hardness' })
|
||||
expect(screen.getByText('Hardness')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should expose min, max, step and modelValue on the input', () => {
|
||||
renderComponent({ min: 0, max: 50, step: 5, modelValue: 25 })
|
||||
|
||||
const input = screen.getByRole('slider') as HTMLInputElement
|
||||
expect(input.min).toBe('0')
|
||||
expect(input.max).toBe('50')
|
||||
expect(input.step).toBe('5')
|
||||
expect(input.value).toBe('25')
|
||||
})
|
||||
|
||||
it('should default step to 1 when not provided', () => {
|
||||
renderComponent({ min: 0, max: 10, modelValue: 5 })
|
||||
|
||||
expect((screen.getByRole('slider') as HTMLInputElement).step).toBe('1')
|
||||
})
|
||||
|
||||
it('should emit update:modelValue with a number when input changes', () => {
|
||||
const onUpdate = vi.fn()
|
||||
renderComponent({ min: 1, max: 100, modelValue: 10 }, onUpdate)
|
||||
|
||||
setSliderValue(screen.getByRole('slider') as HTMLInputElement, '42')
|
||||
|
||||
expect(onUpdate).toHaveBeenLastCalledWith(42)
|
||||
expect(typeof onUpdate.mock.calls.at(-1)![0]).toBe('number')
|
||||
})
|
||||
})
|
||||
60
src/components/maskeditor/controls/ToggleControl.test.ts
Normal file
60
src/components/maskeditor/controls/ToggleControl.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import ToggleControl from './ToggleControl.vue'
|
||||
|
||||
const renderComponent = (
|
||||
props: { label?: string; modelValue?: boolean } = {},
|
||||
onUpdate?: (value: boolean) => void
|
||||
) => {
|
||||
const user = userEvent.setup()
|
||||
const utils = render(ToggleControl, {
|
||||
props: {
|
||||
label: 'Smoothing',
|
||||
modelValue: false,
|
||||
...props,
|
||||
'onUpdate:modelValue': onUpdate
|
||||
}
|
||||
})
|
||||
return { user, ...utils }
|
||||
}
|
||||
|
||||
describe('ToggleControl', () => {
|
||||
it('should render the label', () => {
|
||||
renderComponent({ label: 'Pressure Sensitivity' })
|
||||
expect(screen.getByText('Pressure Sensitivity')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should reflect modelValue=false as unchecked', () => {
|
||||
renderComponent({ modelValue: false })
|
||||
expect((screen.getByRole('checkbox') as HTMLInputElement).checked).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('should reflect modelValue=true as checked', () => {
|
||||
renderComponent({ modelValue: true })
|
||||
expect((screen.getByRole('checkbox') as HTMLInputElement).checked).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('should emit update:modelValue=true when toggled on', async () => {
|
||||
const onUpdate = vi.fn()
|
||||
const { user } = renderComponent({ modelValue: false }, onUpdate)
|
||||
|
||||
await user.click(screen.getByRole('checkbox'))
|
||||
|
||||
expect(onUpdate).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('should emit update:modelValue=false when toggled off', async () => {
|
||||
const onUpdate = vi.fn()
|
||||
const { user } = renderComponent({ modelValue: true }, onUpdate)
|
||||
|
||||
await user.click(screen.getByRole('checkbox'))
|
||||
|
||||
expect(onUpdate).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
380
src/composables/maskeditor/useCoordinateTransform.test.ts
Normal file
380
src/composables/maskeditor/useCoordinateTransform.test.ts
Normal file
@@ -0,0 +1,380 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useCoordinateTransform } from '@/composables/maskeditor/useCoordinateTransform'
|
||||
|
||||
type MockStore = {
|
||||
pointerZone: HTMLElement | null
|
||||
canvasContainer: HTMLElement | null
|
||||
maskCanvas: HTMLCanvasElement | null
|
||||
}
|
||||
|
||||
const mockStore: MockStore = {
|
||||
pointerZone: null,
|
||||
canvasContainer: null,
|
||||
maskCanvas: null
|
||||
}
|
||||
|
||||
vi.mock('@/stores/maskEditorStore', () => ({
|
||||
useMaskEditorStore: vi.fn(() => mockStore)
|
||||
}))
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
createSharedComposable: <T extends (...args: unknown[]) => unknown>(fn: T) =>
|
||||
fn
|
||||
}))
|
||||
|
||||
const createElementWithRect = (rect: Partial<DOMRect>): HTMLElement => {
|
||||
const el = document.createElement('div')
|
||||
vi.spyOn(el, 'getBoundingClientRect').mockReturnValue({
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
...rect
|
||||
} as DOMRect)
|
||||
return el
|
||||
}
|
||||
|
||||
const createCanvasWithRect = (
|
||||
rect: Partial<DOMRect>,
|
||||
width: number,
|
||||
height: number
|
||||
): HTMLCanvasElement => {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
vi.spyOn(canvas, 'getBoundingClientRect').mockReturnValue({
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
...rect
|
||||
} as DOMRect)
|
||||
return canvas
|
||||
}
|
||||
|
||||
describe('useCoordinateTransform', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockStore.pointerZone = null
|
||||
mockStore.canvasContainer = null
|
||||
mockStore.maskCanvas = null
|
||||
})
|
||||
|
||||
describe('screenToCanvas', () => {
|
||||
it('should return canvas coordinates when display size matches canvas size', () => {
|
||||
mockStore.pointerZone = createElementWithRect({
|
||||
left: 100,
|
||||
top: 50,
|
||||
width: 200,
|
||||
height: 200
|
||||
})
|
||||
mockStore.canvasContainer = createElementWithRect({
|
||||
left: 100,
|
||||
top: 50,
|
||||
width: 200,
|
||||
height: 200
|
||||
})
|
||||
mockStore.maskCanvas = createCanvasWithRect(
|
||||
{ left: 100, top: 50, width: 200, height: 200 },
|
||||
200,
|
||||
200
|
||||
)
|
||||
|
||||
const transform = useCoordinateTransform()
|
||||
|
||||
expect(transform.screenToCanvas({ x: 50, y: 30 })).toEqual({
|
||||
x: 50,
|
||||
y: 30
|
||||
})
|
||||
})
|
||||
|
||||
it('should apply scale when canvas is rendered smaller than its bitmap', () => {
|
||||
mockStore.pointerZone = createElementWithRect({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 100,
|
||||
height: 100
|
||||
})
|
||||
mockStore.canvasContainer = createElementWithRect({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 100,
|
||||
height: 100
|
||||
})
|
||||
mockStore.maskCanvas = createCanvasWithRect(
|
||||
{ left: 0, top: 0, width: 100, height: 100 },
|
||||
400,
|
||||
400
|
||||
)
|
||||
|
||||
const transform = useCoordinateTransform()
|
||||
|
||||
expect(transform.screenToCanvas({ x: 25, y: 50 })).toEqual({
|
||||
x: 100,
|
||||
y: 200
|
||||
})
|
||||
})
|
||||
|
||||
it('should account for pointerZone offset relative to canvasContainer', () => {
|
||||
mockStore.pointerZone = createElementWithRect({
|
||||
left: 200,
|
||||
top: 100,
|
||||
width: 200,
|
||||
height: 200
|
||||
})
|
||||
mockStore.canvasContainer = createElementWithRect({
|
||||
left: 150,
|
||||
top: 80,
|
||||
width: 200,
|
||||
height: 200
|
||||
})
|
||||
mockStore.maskCanvas = createCanvasWithRect(
|
||||
{ left: 150, top: 80, width: 200, height: 200 },
|
||||
200,
|
||||
200
|
||||
)
|
||||
|
||||
const transform = useCoordinateTransform()
|
||||
|
||||
expect(transform.screenToCanvas({ x: 0, y: 0 })).toEqual({
|
||||
x: 50,
|
||||
y: 20
|
||||
})
|
||||
})
|
||||
|
||||
it('should support non-uniform scale factors', () => {
|
||||
mockStore.pointerZone = createElementWithRect({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 100,
|
||||
height: 50
|
||||
})
|
||||
mockStore.canvasContainer = createElementWithRect({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 100,
|
||||
height: 50
|
||||
})
|
||||
mockStore.maskCanvas = createCanvasWithRect(
|
||||
{ left: 0, top: 0, width: 100, height: 50 },
|
||||
200,
|
||||
200
|
||||
)
|
||||
|
||||
const transform = useCoordinateTransform()
|
||||
|
||||
expect(transform.screenToCanvas({ x: 10, y: 10 })).toEqual({
|
||||
x: 20,
|
||||
y: 40
|
||||
})
|
||||
})
|
||||
|
||||
it('should return zero point and warn when pointerZone is missing', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
mockStore.canvasContainer = createElementWithRect({})
|
||||
mockStore.maskCanvas = createCanvasWithRect({}, 100, 100)
|
||||
|
||||
const transform = useCoordinateTransform()
|
||||
|
||||
expect(transform.screenToCanvas({ x: 10, y: 20 })).toEqual({ x: 0, y: 0 })
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'screenToCanvas called before elements are available'
|
||||
)
|
||||
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should return zero point when canvasContainer is missing', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
mockStore.pointerZone = createElementWithRect({})
|
||||
mockStore.maskCanvas = createCanvasWithRect({}, 100, 100)
|
||||
|
||||
const transform = useCoordinateTransform()
|
||||
|
||||
expect(transform.screenToCanvas({ x: 10, y: 20 })).toEqual({ x: 0, y: 0 })
|
||||
expect(warnSpy).toHaveBeenCalled()
|
||||
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should return zero point when maskCanvas is missing', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
mockStore.pointerZone = createElementWithRect({})
|
||||
mockStore.canvasContainer = createElementWithRect({})
|
||||
|
||||
const transform = useCoordinateTransform()
|
||||
|
||||
expect(transform.screenToCanvas({ x: 10, y: 20 })).toEqual({ x: 0, y: 0 })
|
||||
expect(warnSpy).toHaveBeenCalled()
|
||||
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('canvasToScreen', () => {
|
||||
it('should return pointerZone-relative coordinates when display matches bitmap', () => {
|
||||
mockStore.pointerZone = createElementWithRect({
|
||||
left: 100,
|
||||
top: 50,
|
||||
width: 200,
|
||||
height: 200
|
||||
})
|
||||
mockStore.canvasContainer = createElementWithRect({
|
||||
left: 100,
|
||||
top: 50,
|
||||
width: 200,
|
||||
height: 200
|
||||
})
|
||||
mockStore.maskCanvas = createCanvasWithRect(
|
||||
{ left: 100, top: 50, width: 200, height: 200 },
|
||||
200,
|
||||
200
|
||||
)
|
||||
|
||||
const transform = useCoordinateTransform()
|
||||
|
||||
expect(transform.canvasToScreen({ x: 50, y: 30 })).toEqual({
|
||||
x: 50,
|
||||
y: 30
|
||||
})
|
||||
})
|
||||
|
||||
it('should apply inverse scale when canvas bitmap is larger than display', () => {
|
||||
mockStore.pointerZone = createElementWithRect({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 100,
|
||||
height: 100
|
||||
})
|
||||
mockStore.canvasContainer = createElementWithRect({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 100,
|
||||
height: 100
|
||||
})
|
||||
mockStore.maskCanvas = createCanvasWithRect(
|
||||
{ left: 0, top: 0, width: 100, height: 100 },
|
||||
400,
|
||||
400
|
||||
)
|
||||
|
||||
const transform = useCoordinateTransform()
|
||||
|
||||
expect(transform.canvasToScreen({ x: 100, y: 200 })).toEqual({
|
||||
x: 25,
|
||||
y: 50
|
||||
})
|
||||
})
|
||||
|
||||
it('should account for pointerZone offset relative to canvasContainer', () => {
|
||||
mockStore.pointerZone = createElementWithRect({
|
||||
left: 200,
|
||||
top: 100,
|
||||
width: 200,
|
||||
height: 200
|
||||
})
|
||||
mockStore.canvasContainer = createElementWithRect({
|
||||
left: 150,
|
||||
top: 80,
|
||||
width: 200,
|
||||
height: 200
|
||||
})
|
||||
mockStore.maskCanvas = createCanvasWithRect(
|
||||
{ left: 150, top: 80, width: 200, height: 200 },
|
||||
200,
|
||||
200
|
||||
)
|
||||
|
||||
const transform = useCoordinateTransform()
|
||||
|
||||
expect(transform.canvasToScreen({ x: 50, y: 20 })).toEqual({ x: 0, y: 0 })
|
||||
})
|
||||
|
||||
it('should round-trip with screenToCanvas', () => {
|
||||
mockStore.pointerZone = createElementWithRect({
|
||||
left: 50,
|
||||
top: 25,
|
||||
width: 300,
|
||||
height: 300
|
||||
})
|
||||
mockStore.canvasContainer = createElementWithRect({
|
||||
left: 70,
|
||||
top: 40,
|
||||
width: 300,
|
||||
height: 300
|
||||
})
|
||||
mockStore.maskCanvas = createCanvasWithRect(
|
||||
{ left: 70, top: 40, width: 300, height: 300 },
|
||||
600,
|
||||
600
|
||||
)
|
||||
|
||||
const transform = useCoordinateTransform()
|
||||
const original = { x: 123, y: 87 }
|
||||
|
||||
const canvasPoint = transform.screenToCanvas(original)
|
||||
const back = transform.canvasToScreen(canvasPoint)
|
||||
|
||||
expect(back.x).toBeCloseTo(original.x)
|
||||
expect(back.y).toBeCloseTo(original.y)
|
||||
})
|
||||
|
||||
it('should return zero point and warn when pointerZone is missing', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
mockStore.canvasContainer = createElementWithRect({})
|
||||
mockStore.maskCanvas = createCanvasWithRect({}, 100, 100)
|
||||
|
||||
const transform = useCoordinateTransform()
|
||||
|
||||
expect(transform.canvasToScreen({ x: 10, y: 20 })).toEqual({ x: 0, y: 0 })
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'canvasToScreen called before elements are available'
|
||||
)
|
||||
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should return zero point when canvasContainer is missing', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
mockStore.pointerZone = createElementWithRect({})
|
||||
mockStore.maskCanvas = createCanvasWithRect({}, 100, 100)
|
||||
|
||||
const transform = useCoordinateTransform()
|
||||
|
||||
expect(transform.canvasToScreen({ x: 10, y: 20 })).toEqual({ x: 0, y: 0 })
|
||||
expect(warnSpy).toHaveBeenCalled()
|
||||
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should return zero point when maskCanvas is missing', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
mockStore.pointerZone = createElementWithRect({})
|
||||
mockStore.canvasContainer = createElementWithRect({})
|
||||
|
||||
const transform = useCoordinateTransform()
|
||||
|
||||
expect(transform.canvasToScreen({ x: 10, y: 20 })).toEqual({ x: 0, y: 0 })
|
||||
expect(warnSpy).toHaveBeenCalled()
|
||||
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
})
|
||||
209
src/composables/maskeditor/useKeyboard.test.ts
Normal file
209
src/composables/maskeditor/useKeyboard.test.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useKeyboard } from '@/composables/maskeditor/useKeyboard'
|
||||
|
||||
type MockCanvasHistory = {
|
||||
undo: ReturnType<typeof vi.fn>
|
||||
redo: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
type MockStore = {
|
||||
canvasHistory: MockCanvasHistory
|
||||
}
|
||||
|
||||
const { mockStore, mockCanvasHistory } = vi.hoisted(() => {
|
||||
const mockCanvasHistory: MockCanvasHistory = {
|
||||
undo: vi.fn(),
|
||||
redo: vi.fn()
|
||||
}
|
||||
|
||||
const mockStore: MockStore = {
|
||||
canvasHistory: mockCanvasHistory
|
||||
}
|
||||
|
||||
return { mockStore, mockCanvasHistory }
|
||||
})
|
||||
|
||||
vi.mock('@/stores/maskEditorStore', () => ({
|
||||
useMaskEditorStore: vi.fn(() => mockStore)
|
||||
}))
|
||||
|
||||
const dispatchKeyDown = (
|
||||
init: KeyboardEventInit & { key: string }
|
||||
): KeyboardEvent => {
|
||||
const event = new KeyboardEvent('keydown', { cancelable: true, ...init })
|
||||
document.dispatchEvent(event)
|
||||
return event
|
||||
}
|
||||
|
||||
const dispatchKeyUp = (key: string): void => {
|
||||
document.dispatchEvent(new KeyboardEvent('keyup', { key }))
|
||||
}
|
||||
|
||||
describe('useKeyboard', () => {
|
||||
let keyboard: ReturnType<typeof useKeyboard>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
document.body.innerHTML = ''
|
||||
keyboard = useKeyboard()
|
||||
keyboard.addListeners()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
keyboard.removeListeners()
|
||||
})
|
||||
|
||||
describe('isKeyDown', () => {
|
||||
it('should return false for keys that have not been pressed', () => {
|
||||
expect(keyboard.isKeyDown('a')).toBe(false)
|
||||
})
|
||||
|
||||
it('should return true after a key is pressed', () => {
|
||||
dispatchKeyDown({ key: 'a' })
|
||||
|
||||
expect(keyboard.isKeyDown('a')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false after a pressed key is released', () => {
|
||||
dispatchKeyDown({ key: 'a' })
|
||||
dispatchKeyUp('a')
|
||||
|
||||
expect(keyboard.isKeyDown('a')).toBe(false)
|
||||
})
|
||||
|
||||
it('should track multiple keys independently', () => {
|
||||
dispatchKeyDown({ key: 'a' })
|
||||
dispatchKeyDown({ key: 'b' })
|
||||
|
||||
expect(keyboard.isKeyDown('a')).toBe(true)
|
||||
expect(keyboard.isKeyDown('b')).toBe(true)
|
||||
|
||||
dispatchKeyUp('a')
|
||||
|
||||
expect(keyboard.isKeyDown('a')).toBe(false)
|
||||
expect(keyboard.isKeyDown('b')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleKeyDown', () => {
|
||||
it('should not duplicate the same key on repeated keydown events', () => {
|
||||
dispatchKeyDown({ key: 'a' })
|
||||
dispatchKeyDown({ key: 'a' })
|
||||
dispatchKeyDown({ key: 'a' })
|
||||
dispatchKeyUp('a')
|
||||
|
||||
expect(keyboard.isKeyDown('a')).toBe(false)
|
||||
})
|
||||
|
||||
it('should prevent default and blur the active element on space', () => {
|
||||
const input = document.createElement('input')
|
||||
document.body.appendChild(input)
|
||||
input.focus()
|
||||
const blurSpy = vi.spyOn(input, 'blur')
|
||||
|
||||
const event = dispatchKeyDown({ key: ' ' })
|
||||
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
expect(blurSpy).toHaveBeenCalledTimes(1)
|
||||
expect(keyboard.isKeyDown(' ')).toBe(true)
|
||||
})
|
||||
|
||||
it('should not throw when activeElement is null', () => {
|
||||
Object.defineProperty(document, 'activeElement', {
|
||||
value: null,
|
||||
configurable: true
|
||||
})
|
||||
|
||||
try {
|
||||
expect(() => dispatchKeyDown({ key: ' ' })).not.toThrow()
|
||||
} finally {
|
||||
Reflect.deleteProperty(document, 'activeElement')
|
||||
}
|
||||
})
|
||||
|
||||
it('should call undo on Ctrl+Z without shift', () => {
|
||||
dispatchKeyDown({ key: 'z', ctrlKey: true })
|
||||
|
||||
expect(mockCanvasHistory.undo).toHaveBeenCalledTimes(1)
|
||||
expect(mockCanvasHistory.redo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call undo on Meta+Z without shift', () => {
|
||||
dispatchKeyDown({ key: 'z', metaKey: true })
|
||||
|
||||
expect(mockCanvasHistory.undo).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call redo on Ctrl+Shift+Z', () => {
|
||||
dispatchKeyDown({ key: 'Z', ctrlKey: true, shiftKey: true })
|
||||
|
||||
expect(mockCanvasHistory.redo).toHaveBeenCalledTimes(1)
|
||||
expect(mockCanvasHistory.undo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call redo on Ctrl+Y', () => {
|
||||
dispatchKeyDown({ key: 'y', ctrlKey: true })
|
||||
|
||||
expect(mockCanvasHistory.redo).toHaveBeenCalledTimes(1)
|
||||
expect(mockCanvasHistory.undo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not trigger undo or redo when alt is held', () => {
|
||||
dispatchKeyDown({ key: 'z', ctrlKey: true, altKey: true })
|
||||
dispatchKeyDown({ key: 'y', ctrlKey: true, altKey: true })
|
||||
|
||||
expect(mockCanvasHistory.undo).not.toHaveBeenCalled()
|
||||
expect(mockCanvasHistory.redo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not trigger undo or redo without ctrl or meta', () => {
|
||||
dispatchKeyDown({ key: 'z' })
|
||||
dispatchKeyDown({ key: 'y' })
|
||||
dispatchKeyDown({ key: 'Z', shiftKey: true })
|
||||
|
||||
expect(mockCanvasHistory.undo).not.toHaveBeenCalled()
|
||||
expect(mockCanvasHistory.redo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should ignore Ctrl+Shift+Y', () => {
|
||||
dispatchKeyDown({ key: 'Y', ctrlKey: true, shiftKey: true })
|
||||
|
||||
expect(mockCanvasHistory.redo).not.toHaveBeenCalled()
|
||||
expect(mockCanvasHistory.undo).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('addListeners', () => {
|
||||
it('should clear all tracked keys when the window loses focus', () => {
|
||||
dispatchKeyDown({ key: 'a' })
|
||||
dispatchKeyDown({ key: 'b' })
|
||||
|
||||
window.dispatchEvent(new Event('blur'))
|
||||
|
||||
expect(keyboard.isKeyDown('a')).toBe(false)
|
||||
expect(keyboard.isKeyDown('b')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeListeners', () => {
|
||||
it('should stop responding to keyboard events after removal', () => {
|
||||
keyboard.removeListeners()
|
||||
|
||||
dispatchKeyDown({ key: 'a' })
|
||||
dispatchKeyDown({ key: 'z', ctrlKey: true })
|
||||
|
||||
expect(keyboard.isKeyDown('a')).toBe(false)
|
||||
expect(mockCanvasHistory.undo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should stop clearing keys on window blur after removal', () => {
|
||||
dispatchKeyDown({ key: 'a' })
|
||||
keyboard.removeListeners()
|
||||
|
||||
window.dispatchEvent(new Event('blur'))
|
||||
|
||||
expect(keyboard.isKeyDown('a')).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
118
src/composables/maskeditor/useMaskEditor.test.ts
Normal file
118
src/composables/maskeditor/useMaskEditor.test.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
|
||||
const mockDialogStore = vi.hoisted(() => ({
|
||||
showDialog: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: () => mockDialogStore
|
||||
}))
|
||||
|
||||
vi.mock('@/components/maskeditor/dialog/TopBarHeader.vue', () => ({
|
||||
default: { name: 'TopBarHeaderStub' }
|
||||
}))
|
||||
|
||||
vi.mock('@/components/maskeditor/MaskEditorContent.vue', () => ({
|
||||
default: { name: 'MaskEditorContentStub' }
|
||||
}))
|
||||
|
||||
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
|
||||
|
||||
type NodeShape = {
|
||||
imgs?: unknown[]
|
||||
previewMediaType?: string
|
||||
}
|
||||
|
||||
const nodeWithImage = (overrides: NodeShape = {}): LGraphNode =>
|
||||
({
|
||||
imgs: [new Image()],
|
||||
previewMediaType: undefined,
|
||||
...overrides
|
||||
}) as unknown as LGraphNode
|
||||
|
||||
describe('useMaskEditor', () => {
|
||||
let errorSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
describe('openMaskEditor', () => {
|
||||
it('should open the dialog with the node forwarded as a prop', () => {
|
||||
const node = nodeWithImage()
|
||||
|
||||
useMaskEditor().openMaskEditor(node)
|
||||
|
||||
expect(mockDialogStore.showDialog).toHaveBeenCalledTimes(1)
|
||||
expect(mockDialogStore.showDialog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
key: 'global-mask-editor',
|
||||
props: { node }
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should pass header and content components to the dialog', () => {
|
||||
useMaskEditor().openMaskEditor(nodeWithImage())
|
||||
|
||||
expect(mockDialogStore.showDialog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
headerComponent: expect.anything(),
|
||||
component: expect.anything()
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should configure modal dialog with maximizable and closable flags', () => {
|
||||
useMaskEditor().openMaskEditor(nodeWithImage())
|
||||
|
||||
expect(mockDialogStore.showDialog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
dialogComponentProps: expect.objectContaining({
|
||||
modal: true,
|
||||
maximizable: true,
|
||||
closable: true
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should accept a node whose previewMediaType is "image" without imgs', () => {
|
||||
const node = nodeWithImage({
|
||||
imgs: undefined,
|
||||
previewMediaType: 'image'
|
||||
})
|
||||
|
||||
useMaskEditor().openMaskEditor(node)
|
||||
|
||||
expect(mockDialogStore.showDialog).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should log and bail when node is null', () => {
|
||||
useMaskEditor().openMaskEditor(null as unknown as LGraphNode)
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledWith('[MaskEditor] No node provided')
|
||||
expect(mockDialogStore.showDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should log and bail when node has neither imgs nor image preview', () => {
|
||||
const node = nodeWithImage({ imgs: [], previewMediaType: undefined })
|
||||
|
||||
useMaskEditor().openMaskEditor(node)
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledWith('[MaskEditor] Node has no images')
|
||||
expect(mockDialogStore.showDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should bail when node has empty imgs and a non-image preview type', () => {
|
||||
const node = nodeWithImage({ imgs: [], previewMediaType: 'video' })
|
||||
|
||||
useMaskEditor().openMaskEditor(node)
|
||||
|
||||
expect(mockDialogStore.showDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
517
src/composables/maskeditor/useToolManager.test.ts
Normal file
517
src/composables/maskeditor/useToolManager.test.ts
Normal file
@@ -0,0 +1,517 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { effectScope, nextTick, reactive } from 'vue'
|
||||
import type { EffectScope } from 'vue'
|
||||
|
||||
import { useBrushDrawing } from '@/composables/maskeditor/useBrushDrawing'
|
||||
import { useToolManager } from '@/composables/maskeditor/useToolManager'
|
||||
import { Tools } from '@/extensions/core/maskeditor/types'
|
||||
|
||||
type MockStore = {
|
||||
currentTool: Tools
|
||||
activeLayer: 'mask' | 'rgb'
|
||||
pointerZone: HTMLElement | null
|
||||
brushVisible: boolean
|
||||
brushPreviewGradientVisible: boolean
|
||||
isAdjustingBrush: boolean
|
||||
isPanning: boolean
|
||||
}
|
||||
|
||||
const mockStore: MockStore = reactive({
|
||||
currentTool: Tools.MaskPen,
|
||||
activeLayer: 'mask',
|
||||
pointerZone: null,
|
||||
brushVisible: true,
|
||||
brushPreviewGradientVisible: false,
|
||||
isAdjustingBrush: false,
|
||||
isPanning: false
|
||||
}) as MockStore
|
||||
|
||||
const mockBrushDrawing = {
|
||||
startDrawing: vi.fn().mockResolvedValue(undefined),
|
||||
handleDrawing: vi.fn().mockResolvedValue(undefined),
|
||||
drawEnd: vi.fn().mockResolvedValue(undefined),
|
||||
startBrushAdjustment: vi.fn().mockResolvedValue(undefined),
|
||||
handleBrushAdjustment: vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
|
||||
const mockCanvasTools = {
|
||||
paintBucketFill: vi.fn(),
|
||||
colorSelectFill: vi.fn().mockResolvedValue(undefined),
|
||||
clearLastColorSelectPoint: vi.fn()
|
||||
}
|
||||
|
||||
const mockCoordinateTransform = {
|
||||
screenToCanvas: vi.fn((p: { x: number; y: number }) => ({
|
||||
x: p.x * 2,
|
||||
y: p.y * 2
|
||||
})),
|
||||
canvasToScreen: vi.fn()
|
||||
}
|
||||
|
||||
vi.mock('@/stores/maskEditorStore', () => ({
|
||||
useMaskEditorStore: vi.fn(() => mockStore)
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/maskeditor/useBrushDrawing', () => ({
|
||||
useBrushDrawing: vi.fn(() => mockBrushDrawing)
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/maskeditor/useCanvasTools', () => ({
|
||||
useCanvasTools: vi.fn(() => mockCanvasTools)
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/maskeditor/useCoordinateTransform', () => ({
|
||||
useCoordinateTransform: vi.fn(() => mockCoordinateTransform)
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
extensionManager: {
|
||||
setting: {
|
||||
get: vi.fn((key: string) => {
|
||||
if (key === 'Comfy.MaskEditor.UseDominantAxis') return false
|
||||
if (key === 'Comfy.MaskEditor.BrushAdjustmentSpeed') return 1
|
||||
return undefined
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
const mockKeyboard = {
|
||||
isKeyDown: vi.fn().mockReturnValue(false),
|
||||
addListeners: vi.fn(),
|
||||
removeListeners: vi.fn()
|
||||
}
|
||||
|
||||
const mockPanZoom = {
|
||||
initializeCanvasPanZoom: vi.fn(),
|
||||
handlePanStart: vi.fn(),
|
||||
handlePanMove: vi.fn().mockResolvedValue(undefined),
|
||||
handleTouchStart: vi.fn(),
|
||||
handleTouchMove: vi.fn(),
|
||||
handleTouchEnd: vi.fn(),
|
||||
updateCursorPosition: vi.fn(),
|
||||
zoom: vi.fn(),
|
||||
invalidatePanZoom: vi.fn(),
|
||||
addPenPointerId: vi.fn(),
|
||||
removePenPointerId: vi.fn()
|
||||
}
|
||||
|
||||
const pointerEvent = (
|
||||
init: Partial<PointerEvent> & { pointerType?: string }
|
||||
): PointerEvent => {
|
||||
return {
|
||||
preventDefault: vi.fn(),
|
||||
pointerId: 1,
|
||||
pointerType: 'mouse',
|
||||
button: 0,
|
||||
buttons: 0,
|
||||
clientX: 0,
|
||||
clientY: 0,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
altKey: false,
|
||||
...init
|
||||
} as unknown as PointerEvent
|
||||
}
|
||||
|
||||
let scope: EffectScope | null = null
|
||||
|
||||
const setup = (): ReturnType<typeof useToolManager> => {
|
||||
scope = effectScope()
|
||||
return scope.run(() =>
|
||||
useToolManager(
|
||||
mockKeyboard as unknown as Parameters<typeof useToolManager>[0],
|
||||
mockPanZoom as unknown as Parameters<typeof useToolManager>[1]
|
||||
)
|
||||
)!
|
||||
}
|
||||
|
||||
describe('useToolManager', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockStore.currentTool = Tools.MaskPen
|
||||
mockStore.activeLayer = 'mask'
|
||||
mockStore.pointerZone = document.createElement('div')
|
||||
mockStore.brushVisible = true
|
||||
mockStore.brushPreviewGradientVisible = false
|
||||
mockStore.isAdjustingBrush = false
|
||||
mockStore.isPanning = false
|
||||
mockKeyboard.isKeyDown.mockReturnValue(false)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
scope?.stop()
|
||||
scope = null
|
||||
})
|
||||
|
||||
describe('useBrushDrawing factory', () => {
|
||||
it('should construct useBrushDrawing with settings from the extension manager', () => {
|
||||
setup()
|
||||
|
||||
expect(useBrushDrawing).toHaveBeenCalledWith({
|
||||
useDominantAxis: false,
|
||||
brushAdjustmentSpeed: 1
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('switchTool', () => {
|
||||
it('should set the current tool on the store', () => {
|
||||
const tm = setup()
|
||||
tm.switchTool(Tools.Eraser)
|
||||
expect(mockStore.currentTool).toBe(Tools.Eraser)
|
||||
})
|
||||
|
||||
it('should update activeLayer to "rgb" when switching to PaintPen', () => {
|
||||
const tm = setup()
|
||||
tm.switchTool(Tools.PaintPen)
|
||||
expect(mockStore.activeLayer).toBe('rgb')
|
||||
})
|
||||
|
||||
it('should update activeLayer to "mask" when switching to MaskPen', () => {
|
||||
const tm = setup()
|
||||
mockStore.activeLayer = 'rgb'
|
||||
tm.switchTool(Tools.MaskPen)
|
||||
expect(mockStore.activeLayer).toBe('mask')
|
||||
})
|
||||
|
||||
it('should set custom cursor and hide brush for MaskBucket', () => {
|
||||
const tm = setup()
|
||||
tm.switchTool(Tools.MaskBucket)
|
||||
expect(mockStore.brushVisible).toBe(false)
|
||||
expect(mockStore.pointerZone!.style.cursor).toContain('paintBucket.png')
|
||||
})
|
||||
|
||||
it('should reset cursor to "none" and show brush for tools without custom cursor', () => {
|
||||
const tm = setup()
|
||||
tm.switchTool(Tools.MaskBucket)
|
||||
expect(mockStore.brushVisible).toBe(false)
|
||||
|
||||
tm.switchTool(Tools.MaskPen)
|
||||
expect(mockStore.brushVisible).toBe(true)
|
||||
expect(mockStore.pointerZone!.style.cursor).toBe('none')
|
||||
})
|
||||
|
||||
it('should not touch cursor when pointerZone is missing', () => {
|
||||
const tm = setup()
|
||||
mockStore.pointerZone = null
|
||||
expect(() => tm.switchTool(Tools.MaskBucket)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('setActiveLayer', () => {
|
||||
it('should switch from mask-only tool to PaintPen when activating rgb layer', () => {
|
||||
const tm = setup()
|
||||
mockStore.currentTool = Tools.MaskBucket
|
||||
|
||||
tm.setActiveLayer('rgb')
|
||||
|
||||
expect(mockStore.activeLayer).toBe('rgb')
|
||||
expect(mockStore.currentTool).toBe(Tools.PaintPen)
|
||||
})
|
||||
|
||||
it('should switch from PaintPen to MaskPen when activating mask layer', () => {
|
||||
const tm = setup()
|
||||
mockStore.currentTool = Tools.PaintPen
|
||||
|
||||
tm.setActiveLayer('mask')
|
||||
|
||||
expect(mockStore.activeLayer).toBe('mask')
|
||||
expect(mockStore.currentTool).toBe(Tools.MaskPen)
|
||||
})
|
||||
|
||||
it('should leave a non-mask-only tool alone when activating rgb', () => {
|
||||
const tm = setup()
|
||||
mockStore.currentTool = Tools.Eraser
|
||||
|
||||
tm.setActiveLayer('rgb')
|
||||
|
||||
expect(mockStore.currentTool).toBe(Tools.Eraser)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateCursor', () => {
|
||||
it('should hide brush and set custom cursor for tools that define one', () => {
|
||||
const tm = setup()
|
||||
mockStore.currentTool = Tools.MaskColorFill
|
||||
|
||||
tm.updateCursor()
|
||||
|
||||
expect(mockStore.brushVisible).toBe(false)
|
||||
expect(mockStore.pointerZone!.style.cursor).toContain('colorSelect.png')
|
||||
expect(mockStore.brushPreviewGradientVisible).toBe(false)
|
||||
})
|
||||
|
||||
it('should show brush and "none" cursor for tools without a custom cursor', () => {
|
||||
const tm = setup()
|
||||
mockStore.currentTool = Tools.MaskPen
|
||||
|
||||
tm.updateCursor()
|
||||
|
||||
expect(mockStore.brushVisible).toBe(true)
|
||||
expect(mockStore.pointerZone!.style.cursor).toBe('none')
|
||||
})
|
||||
})
|
||||
|
||||
describe('currentTool watcher', () => {
|
||||
it('should clear last color-select point when switching away from MaskColorFill', async () => {
|
||||
setup()
|
||||
mockStore.currentTool = Tools.MaskColorFill
|
||||
await nextTick()
|
||||
mockCanvasTools.clearLastColorSelectPoint.mockClear()
|
||||
|
||||
mockStore.currentTool = Tools.MaskPen
|
||||
await nextTick()
|
||||
|
||||
expect(mockCanvasTools.clearLastColorSelectPoint).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not clear color-select point when switching to MaskColorFill', async () => {
|
||||
setup()
|
||||
mockStore.currentTool = Tools.MaskPen
|
||||
await nextTick()
|
||||
mockCanvasTools.clearLastColorSelectPoint.mockClear()
|
||||
|
||||
mockStore.currentTool = Tools.MaskColorFill
|
||||
await nextTick()
|
||||
|
||||
expect(mockCanvasTools.clearLastColorSelectPoint).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('handlePointerDown', () => {
|
||||
it('should ignore touch pointers entirely', async () => {
|
||||
const tm = setup()
|
||||
await tm.handlePointerDown(pointerEvent({ pointerType: 'touch' }))
|
||||
|
||||
expect(mockBrushDrawing.startDrawing).not.toHaveBeenCalled()
|
||||
expect(mockPanZoom.handlePanStart).not.toHaveBeenCalled()
|
||||
expect(mockPanZoom.addPenPointerId).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should register pen pointer id then continue tool routing', async () => {
|
||||
const tm = setup()
|
||||
await tm.handlePointerDown(
|
||||
pointerEvent({
|
||||
pointerType: 'pen',
|
||||
button: 0,
|
||||
buttons: 1,
|
||||
pointerId: 7
|
||||
})
|
||||
)
|
||||
|
||||
expect(mockPanZoom.addPenPointerId).toHaveBeenCalledWith(7)
|
||||
})
|
||||
|
||||
it('should start panning on middle mouse button (buttons===4)', async () => {
|
||||
const tm = setup()
|
||||
await tm.handlePointerDown(pointerEvent({ buttons: 4 }))
|
||||
|
||||
expect(mockPanZoom.handlePanStart).toHaveBeenCalled()
|
||||
expect(mockStore.brushVisible).toBe(false)
|
||||
expect(mockBrushDrawing.startDrawing).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should start panning on left button + space held', async () => {
|
||||
const tm = setup()
|
||||
mockKeyboard.isKeyDown.mockImplementation((k) => k === ' ')
|
||||
|
||||
await tm.handlePointerDown(pointerEvent({ buttons: 1 }))
|
||||
|
||||
expect(mockPanZoom.handlePanStart).toHaveBeenCalled()
|
||||
expect(mockBrushDrawing.startDrawing).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should start drawing for MaskPen on left button', async () => {
|
||||
const tm = setup()
|
||||
mockStore.currentTool = Tools.MaskPen
|
||||
|
||||
await tm.handlePointerDown(pointerEvent({ button: 0, buttons: 1 }))
|
||||
|
||||
expect(mockBrushDrawing.startDrawing).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should start drawing for PaintPen on left button', async () => {
|
||||
const tm = setup()
|
||||
mockStore.currentTool = Tools.PaintPen
|
||||
|
||||
await tm.handlePointerDown(pointerEvent({ button: 0, buttons: 1 }))
|
||||
|
||||
expect(mockBrushDrawing.startDrawing).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should continue drawing for PaintPen when a non-left button fires while left is held', async () => {
|
||||
const tm = setup()
|
||||
mockStore.currentTool = Tools.PaintPen
|
||||
|
||||
await tm.handlePointerDown(pointerEvent({ button: 2, buttons: 1 }))
|
||||
|
||||
expect(mockBrushDrawing.handleDrawing).toHaveBeenCalledTimes(1)
|
||||
expect(mockBrushDrawing.startDrawing).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call paintBucketFill on MaskBucket left click using transformed coords', async () => {
|
||||
const tm = setup()
|
||||
mockStore.currentTool = Tools.MaskBucket
|
||||
|
||||
await tm.handlePointerDown(
|
||||
pointerEvent({ button: 0, offsetX: 50, offsetY: 25 })
|
||||
)
|
||||
|
||||
expect(mockCoordinateTransform.screenToCanvas).toHaveBeenCalledWith({
|
||||
x: 50,
|
||||
y: 25
|
||||
})
|
||||
expect(mockCanvasTools.paintBucketFill).toHaveBeenCalledWith({
|
||||
x: 100,
|
||||
y: 50
|
||||
})
|
||||
})
|
||||
|
||||
it('should call colorSelectFill on MaskColorFill left click', async () => {
|
||||
const tm = setup()
|
||||
mockStore.currentTool = Tools.MaskColorFill
|
||||
|
||||
await tm.handlePointerDown(
|
||||
pointerEvent({ button: 0, offsetX: 10, offsetY: 20 })
|
||||
)
|
||||
|
||||
expect(mockCanvasTools.colorSelectFill).toHaveBeenCalledWith({
|
||||
x: 20,
|
||||
y: 40
|
||||
})
|
||||
})
|
||||
|
||||
it('should start brush adjustment on alt + right-click', async () => {
|
||||
const tm = setup()
|
||||
|
||||
await tm.handlePointerDown(
|
||||
pointerEvent({ altKey: true, button: 2, buttons: 2 })
|
||||
)
|
||||
|
||||
expect(mockStore.isAdjustingBrush).toBe(true)
|
||||
expect(mockBrushDrawing.startBrushAdjustment).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should start drawing on right-click for drawing tools', async () => {
|
||||
const tm = setup()
|
||||
mockStore.currentTool = Tools.Eraser
|
||||
|
||||
await tm.handlePointerDown(pointerEvent({ button: 2, buttons: 2 }))
|
||||
|
||||
expect(mockBrushDrawing.startDrawing).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not start drawing for non-drawing tools', async () => {
|
||||
const tm = setup()
|
||||
mockStore.currentTool = Tools.MaskBucket
|
||||
|
||||
await tm.handlePointerDown(pointerEvent({ button: 2, buttons: 2 }))
|
||||
|
||||
expect(mockBrushDrawing.startDrawing).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('handlePointerMove', () => {
|
||||
it('should ignore touch pointers', async () => {
|
||||
const tm = setup()
|
||||
await tm.handlePointerMove(pointerEvent({ pointerType: 'touch' }))
|
||||
|
||||
expect(mockPanZoom.updateCursorPosition).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should always update cursor position for non-touch pointers', async () => {
|
||||
const tm = setup()
|
||||
await tm.handlePointerMove(pointerEvent({ clientX: 30, clientY: 40 }))
|
||||
|
||||
expect(mockPanZoom.updateCursorPosition).toHaveBeenCalledWith({
|
||||
x: 30,
|
||||
y: 40
|
||||
})
|
||||
})
|
||||
|
||||
it('should pan on middle button drag', async () => {
|
||||
const tm = setup()
|
||||
await tm.handlePointerMove(pointerEvent({ buttons: 4 }))
|
||||
|
||||
expect(mockPanZoom.handlePanMove).toHaveBeenCalled()
|
||||
expect(mockBrushDrawing.handleDrawing).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should pan on left button + space drag', async () => {
|
||||
const tm = setup()
|
||||
mockKeyboard.isKeyDown.mockImplementation((k) => k === ' ')
|
||||
|
||||
await tm.handlePointerMove(pointerEvent({ buttons: 1 }))
|
||||
|
||||
expect(mockPanZoom.handlePanMove).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should ignore drawing for non-drawing tools', async () => {
|
||||
const tm = setup()
|
||||
mockStore.currentTool = Tools.MaskBucket
|
||||
|
||||
await tm.handlePointerMove(pointerEvent({ buttons: 1 }))
|
||||
|
||||
expect(mockBrushDrawing.handleDrawing).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should adjust brush on alt + right-drag while adjusting', async () => {
|
||||
const tm = setup()
|
||||
mockStore.isAdjustingBrush = true
|
||||
mockStore.currentTool = Tools.MaskPen
|
||||
|
||||
await tm.handlePointerMove(pointerEvent({ altKey: true, buttons: 2 }))
|
||||
|
||||
expect(mockBrushDrawing.handleBrushAdjustment).toHaveBeenCalled()
|
||||
expect(mockBrushDrawing.handleDrawing).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call handleDrawing on left or right drag for drawing tools', async () => {
|
||||
const tm = setup()
|
||||
mockStore.currentTool = Tools.MaskPen
|
||||
|
||||
await tm.handlePointerMove(pointerEvent({ buttons: 1 }))
|
||||
expect(mockBrushDrawing.handleDrawing).toHaveBeenCalledTimes(1)
|
||||
|
||||
await tm.handlePointerMove(pointerEvent({ buttons: 2 }))
|
||||
expect(mockBrushDrawing.handleDrawing).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handlePointerUp', () => {
|
||||
it('should reset panning and brush state', async () => {
|
||||
const tm = setup()
|
||||
mockStore.isPanning = true
|
||||
mockStore.brushVisible = false
|
||||
mockStore.isAdjustingBrush = true
|
||||
|
||||
await tm.handlePointerUp(pointerEvent({}))
|
||||
|
||||
expect(mockStore.isPanning).toBe(false)
|
||||
expect(mockStore.brushVisible).toBe(true)
|
||||
expect(mockStore.isAdjustingBrush).toBe(false)
|
||||
expect(mockBrushDrawing.drawEnd).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should remove pen pointer id when pointerType is "pen"', async () => {
|
||||
const tm = setup()
|
||||
|
||||
await tm.handlePointerUp(
|
||||
pointerEvent({ pointerType: 'pen', pointerId: 12 })
|
||||
)
|
||||
|
||||
expect(mockPanZoom.removePenPointerId).toHaveBeenCalledWith(12)
|
||||
})
|
||||
|
||||
it('should bail out before drawEnd for touch pointers', async () => {
|
||||
const tm = setup()
|
||||
|
||||
await tm.handlePointerUp(pointerEvent({ pointerType: 'touch' }))
|
||||
|
||||
expect(mockBrushDrawing.drawEnd).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
161
src/platform/assets/components/AssetCard.test.ts
Normal file
161
src/platform/assets/components/AssetCard.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
93
src/platform/assets/components/MediaAssetFilterMenu.test.ts
Normal file
93
src/platform/assets/components/MediaAssetFilterMenu.test.ts
Normal 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'])
|
||||
})
|
||||
})
|
||||
130
src/platform/assets/components/MediaAssetSettingsMenu.test.ts
Normal file
130
src/platform/assets/components/MediaAssetSettingsMenu.test.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
191
src/platform/assets/composables/useMediaAssetFiltering.test.ts
Normal file
191
src/platform/assets/composables/useMediaAssetFiltering.test.ts
Normal 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'])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 }))
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
159
src/stores/maskEditorDataStore.test.ts
Normal file
159
src/stores/maskEditorDataStore.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
376
src/stores/maskEditorStore.test.ts
Normal file
376
src/stores/maskEditorStore.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user