슬래시 메뉴 위치 관리 분리

This commit is contained in:
2026-07-29 11:24:00 +09:00
parent d8cfa77080
commit 393d173af5
10 changed files with 514 additions and 220 deletions

View File

@@ -0,0 +1,216 @@
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()
}
})