import assert from 'node:assert/strict' import test from 'node:test' import { computed, effectScope, nextTick, ref } from 'vue' import { useAdminMarkdownSlashMenuPosition } from '../composables/useAdminMarkdownSlashMenuPosition.js' /** * 테스트용 브라우저 window 객체를 설치한다. * @returns {{ added: string[], removed: string[], restore: () => void }} 이벤트 기록과 복구 함수 */ const installWindowStub = () => { const previousWindow = globalThis.window const added = [] const removed = [] globalThis.window = { innerHeight: 800, innerWidth: 1200, addEventListener: (eventName) => { added.push(eventName) }, removeEventListener: (eventName) => { removed.push(eventName) } } return { added, removed, restore: () => { if (previousWindow === undefined) { delete globalThis.window return } globalThis.window = previousWindow } } } /** * 슬래시 메뉴 위치 composable 테스트 상태를 생성한다. * @param {Object} [overrides] - 상태 재정의 * @returns {Object} 테스트 상태 */ const createSlashMenuPositionHarness = (overrides = {}) => { const activeMode = ref(overrides.activeMode ?? 'preview') const markdownValue = ref(overrides.markdownValue ?? '/image') const liveSlashSourceLine = ref(overrides.sourceLine ?? 0) const liveSlashQuery = ref(overrides.query ?? 'image') const liveSlashHighlightedIndex = ref(overrides.highlightedIndex ?? 0) const liveSlashMenuPosition = ref({ top: 0, left: 0 }) const liveSlashMenuRef = ref(overrides.menuRef ?? null) const liveSlashVisible = computed(() => liveSlashSourceLine.value !== null) const visibleLiveSlashCommands = ref(overrides.commands ?? [{ id: 'image' }, { id: 'gallery' }]) const resetCalls = [] const clampCalls = [] const previewSurfaceEvents = [] const textareaEvents = [] const previewSurface = { addEventListener: (eventName) => previewSurfaceEvents.push(`add:${eventName}`), removeEventListener: (eventName) => previewSurfaceEvents.push(`remove:${eventName}`) } const textarea = { scrollTop: 0, scrollLeft: 0, addEventListener: (eventName) => textareaEvents.push(`add:${eventName}`), removeEventListener: (eventName) => textareaEvents.push(`remove:${eventName}`), getBoundingClientRect: () => ({ top: 20, left: 30 }) } return { options: { activeMode, markdownValue, liveSlashSourceLine, liveSlashQuery, liveSlashHighlightedIndex, liveSlashMenuPosition, liveSlashMenuRef, liveSlashVisible, visibleLiveSlashCommands, resetLiveSlashHighlightedIndex: () => resetCalls.push('reset'), clampLiveSlashHighlightedIndex: (commandCount) => clampCalls.push(commandCount), getPreviewSourceLineElement: () => ({ getBoundingClientRect: () => ({ top: 100, bottom: 124, left: 48 }) }), getPreviewSurfaceElement: () => previewSurface, getWriteTextareaElement: () => textarea, isClient: true }, activeMode, liveSlashSourceLine, liveSlashQuery, liveSlashHighlightedIndex, liveSlashMenuPosition, liveSlashMenuRef, visibleLiveSlashCommands, resetCalls, clampCalls, previewSurfaceEvents, textareaEvents } } test('관리자 Markdown 슬래시 메뉴 위치는 미리보기 줄 기준으로 갱신한다', async () => { const windowStub = installWindowStub() const scope = effectScope() const harness = createSlashMenuPositionHarness({ menuRef: { offsetHeight: 160, offsetWidth: 240 } }) try { scope.run(() => { const position = useAdminMarkdownSlashMenuPosition(harness.options) position.updateLiveSlashMenuPosition() }) await nextTick() await nextTick() assert.deepEqual(harness.liveSlashMenuPosition.value, { top: 128, left: 48 }) } finally { scope.stop() windowStub.restore() } }) test('관리자 Markdown 슬래시 메뉴 위치는 노출 상태에 맞춰 스크롤 리스너를 정리한다', async () => { const windowStub = installWindowStub() const scope = effectScope() const harness = createSlashMenuPositionHarness() try { scope.run(() => { useAdminMarkdownSlashMenuPosition(harness.options) }) harness.liveSlashSourceLine.value = null await nextTick() harness.liveSlashSourceLine.value = 0 await nextTick() harness.liveSlashSourceLine.value = null await nextTick() assert.deepEqual(windowStub.added, ['scroll', 'resize']) assert.deepEqual(windowStub.removed.slice(-2), ['scroll', 'resize']) assert.deepEqual(harness.previewSurfaceEvents, ['add:scroll', 'remove:scroll']) assert.deepEqual(harness.textareaEvents, ['add:scroll', 'remove:scroll']) } finally { scope.stop() windowStub.restore() } }) test('관리자 Markdown 슬래시 메뉴 하이라이트는 메뉴 안으로 스크롤된다', async () => { const windowStub = installWindowStub() const scope = effectScope() const menu = { offsetHeight: 160, offsetWidth: 240, scrollTop: 0, clientHeight: 80, querySelector: () => ({ offsetTop: 120, offsetHeight: 32 }) } const harness = createSlashMenuPositionHarness({ menuRef: { getRootElement: () => menu } }) try { scope.run(() => { useAdminMarkdownSlashMenuPosition(harness.options) }) harness.liveSlashHighlightedIndex.value = 1 await nextTick() await nextTick() assert.equal(menu.scrollTop, 72) } finally { scope.stop() windowStub.restore() } }) test('관리자 Markdown 슬래시 메뉴 검색어와 명령 수 변경은 하이라이트를 보정한다', async () => { const windowStub = installWindowStub() const scope = effectScope() const harness = createSlashMenuPositionHarness() try { scope.run(() => { useAdminMarkdownSlashMenuPosition(harness.options) }) harness.liveSlashQuery.value = 'callout' harness.visibleLiveSlashCommands.value = [{ id: 'callout' }] await nextTick() assert.deepEqual(harness.resetCalls, ['reset']) assert.deepEqual(harness.clampCalls, [1]) } finally { scope.stop() windowStub.restore() } })