import { expect, test } from '@playwright/test' /** * 격리된 테스트 관리자로 로그인하고 지연·실패를 제어할 미디어 응답을 준비한다. * @param {import('@playwright/test').Page} page - 테스트 페이지 * @param {number} count - 테스트 미디어 개수 * @returns {Promise} 요청과 응답 제어 상태 */ const openMediaLibrary = async (page, count = 3) => { await page.goto('/admin/login') await page.waitForLoadState('networkidle') await page.getByLabel('이메일').fill('playwright-admin@example.com') await page.locator('input[type="password"]').fill('playwright-admin-password') await page.getByRole('button', { name: '로그인', exact: true }).click() await page.waitForURL(/\/admin\/?$/) await page.waitForLoadState('networkidle') const state = { folders: ['미분류', '보관', '빈 폴더'], items: Array.from({ length: count }, (_, index) => ({ url: `/uploads/media-interaction-${index}.webp`, name: `사진-${index + 1}.webp`, title: `사진 ${index + 1}`, category: '미분류', kind: 'image', usage: index === 0 ? [{ title: '사용 중인 게시물' }] : [] })), moves: [], moveGate: null, folderGate: null, failMove: false, failList: false } await page.route('**/uploads/media-interaction-*.webp', (route) => route.fulfill({ contentType: 'image/svg+xml', body: '' })) await page.route('**/admin/api/media-folders', async (route) => { if (route.request().method() === 'POST') { const { path } = route.request().postDataJSON() state.folders.push(path) await route.fulfill({ json: { path } }) return } await route.fulfill({ json: state.folders }) }) await page.route(/\/admin\/api\/media(?:\?|$)/, async (route) => { if (route.request().method() === 'PUT') { const body = route.request().postDataJSON() state.moves.push(body) await state.moveGate?.promise if (state.failMove) { await route.fulfill({ status: 500, json: { message: '이동 실패 테스트' } }) return } state.items.forEach((item) => { if (body.urls.includes(item.url)) item.category = body.category }) await route.fulfill({ json: [] }) return } const query = new URL(route.request().url()).searchParams const folder = query.get('folder') || '' const pageNumber = Number(query.get('page') || 1) const pageSize = Number(query.get('pageSize') || 50) if (folder === '보관') await state.folderGate?.promise if (state.failList) { await route.fulfill({ status: 500, json: { message: '목록 실패 테스트' } }) return } const items = state.items.filter((item) => !folder || item.category === folder) const folders = Object.fromEntries(state.folders.map((path) => [path, state.items.filter((item) => item.category === path).length])) await route.fulfill({ json: { items: items.slice((pageNumber - 1) * pageSize, pageNumber * pageSize), pagination: { page: pageNumber, pageSize, total: items.length, pageCount: Math.max(1, Math.ceil(items.length / pageSize)) }, counts: { tabs: { library: state.items.length }, folders, kinds: { all: items.length, image: items.length }, usage: { all: items.length, unused: items.filter((item) => !item.usage.length).length } } } }) }) await page.getByRole('link', { name: '미디어', exact: true }).click() await expect(page.locator('.admin-media__item')).toHaveCount(Math.min(count, 50)) return state } /** * 실제 마우스로 썸네일 드래그를 시작하고 대상 폴더 위에서 멈춘다. * @param {import('@playwright/test').Page} page - 테스트 페이지 * @param {string} folder - 대상 폴더 * @returns {Promise} */ const dragMediaOverFolder = async (page, folder) => { const source = await page.locator('.admin-media__image').first().boundingBox() const target = await page.getByTitle(folder, { exact: true }).boundingBox() const sourceX = source.x + source.width - 8 const sourceY = source.y + source.height - 8 await page.mouse.move(sourceX, sourceY) await page.mouse.down() await page.mouse.move(sourceX - 15, sourceY, { steps: 5 }) await page.mouse.move(target.x + target.width / 2, target.y + target.height / 2, { steps: 10 }) await page.mouse.move(target.x + target.width / 2 + 1, target.y + target.height / 2) } test('다중 선택·폴더 생성·드롭 강조·일괄 이동 상태를 표시한다', async ({ page }, testInfo) => { const state = await openMediaLibrary(page) await page.getByRole('button', { name: '전체 선택', exact: true }).click() await expect(page.locator('.admin-media__selection-count')).toHaveText('3개 선택됨') await expect(page.locator('.admin-media__item--selected')).toHaveCount(3) await page.getByRole('button', { name: '폴더 추가', exact: true }).click() await page.getByLabel('폴더 이름').fill('새 정리 폴더') await page.getByRole('button', { name: '만들기', exact: true }).click() await expect(page.getByTitle('새 정리 폴더', { exact: true })).toBeVisible() await expect(page.locator('.admin-media__selection-count')).toHaveText('3개 선택됨') state.moveGate = Promise.withResolvers() await dragMediaOverFolder(page, '새 정리 폴더') await expect(page.locator('.admin-media__folder-row--drop-target')).toContainText('놓으면 3개 이동') await expect(page.locator('.admin-media__drag-preview')).toHaveText('미디어 3개 이동') await expect(page.locator('.admin-media__item--dragging')).toHaveCount(3) await page.screenshot({ path: testInfo.outputPath('media-drag.png'), fullPage: true, animations: 'disabled' }) await page.mouse.up() await expect(page.locator('.admin-media__move-status')).toContainText('3개 미디어를 「새 정리 폴더」 폴더로 이동하는 중') await expect(page.locator('.admin-media__select-all')).toBeDisabled() expect(state.moves[0]).toEqual({ urls: state.items.map((item) => item.url), category: '새 정리 폴더' }) state.moveGate.resolve() await expect(page.getByText('3개 미디어를 「새 정리 폴더」 폴더로 이동했습니다.', { exact: true })).toBeVisible() await expect(page.locator('.admin-media__move-status')).toHaveCount(0) await expect(page.locator('.admin-media__selection-count')).toHaveCount(0) await page.getByTitle('새 정리 폴더', { exact: true }).click() await expect(page.locator('.admin-media__item')).toHaveCount(3) }) test('드래그 취소·외부 드롭을 무시하고 이동 실패 시 선택을 유지한다', async ({ page }) => { const state = await openMediaLibrary(page) await page.getByRole('button', { name: '전체 선택', exact: true }).click() await dragMediaOverFolder(page, '보관') await page.keyboard.press('Escape') await page.mouse.up() await expect(page.locator('.admin-media__folder-row--drop-target')).toHaveCount(0) await expect(page.locator('.admin-media__drag-preview')).toHaveCount(0) await expect(page.locator('.admin-media__item--dragging')).toHaveCount(0) await page.getByTitle('보관', { exact: true }).dispatchEvent('drop') expect(state.moves).toHaveLength(0) state.failMove = true await dragMediaOverFolder(page, '보관') await page.mouse.up() await expect(page.getByText('이동 실패 테스트', { exact: true })).toBeVisible() await expect(page.locator('.admin-media__selection-count')).toHaveText('3개 선택됨') await expect(page.locator('.admin-media__move-status')).toHaveCount(0) await expect(page.locator('.admin-media__select-all')).toBeEnabled() }) test('폴더 전환은 이전 목록을 숨기고 마지막 응답·오류·재시도를 구분한다', async ({ page }, testInfo) => { const state = await openMediaLibrary(page) state.folderGate = Promise.withResolvers() await page.getByTitle('보관', { exact: true }).click() await expect(page.locator('.admin-media__loading')).toContainText('보관 불러오는 중') await expect(page.locator('.admin-media__item')).toHaveCount(0) await expect(page.locator('.admin-media__empty')).toHaveCount(0) await expect(page.locator('.admin-media__select-all')).toBeDisabled() await expect(page.getByTitle('미분류', { exact: true })).toBeVisible() await expect(page.locator('.admin-media__folder-row').filter({ has: page.getByTitle('보관', { exact: true }) })).toHaveCSS('background-color', 'rgb(21, 23, 26)') await page.screenshot({ path: testInfo.outputPath('media-loading.png'), fullPage: true, animations: 'disabled' }) await page.getByTitle('미분류', { exact: true }).click() await expect(page.locator('.admin-media__item')).toHaveCount(3) state.folderGate.resolve() await expect(page.locator('.admin-media__folder-title')).toHaveText('미분류') await expect(page.locator('.admin-media__item')).toHaveCount(3) state.failList = true await page.getByTitle('빈 폴더', { exact: true }).click() await expect(page.locator('.admin-media__load-error')).toBeVisible() await expect(page.locator('.admin-media__empty')).toHaveCount(0) state.failList = false await page.getByRole('button', { name: '다시 시도', exact: true }).click() await expect(page.locator('.admin-media__empty')).toBeVisible() await expect(page.locator('.admin-media__load-error')).toHaveCount(0) }) test('전체 선택은 현재 페이지에만 적용하고 사용 중인 파일도 포함한다', async ({ page }) => { await openMediaLibrary(page, 55) await page.getByRole('button', { name: '전체 선택', exact: true }).click() await expect(page.locator('.admin-media__selection-count')).toHaveText('50개 선택됨') await expect(page.locator('.admin-media__select-toggle[aria-pressed="true"]')).toHaveCount(50) await page.getByRole('button', { name: '전체 선택 해제', exact: true }).click() await expect(page.locator('.admin-media__selection-count')).toHaveCount(0) await page.getByRole('button', { name: '다음 페이지', exact: true }).click() await expect(page.locator('.admin-media__item')).toHaveCount(5) await page.getByRole('button', { name: '전체 선택', exact: true }).click() await expect(page.locator('.admin-media__selection-count')).toHaveText('5개 선택됨') })