generated from ttipo/project-base
feat(frontend): 검색에 조건 · 강조 · 준비 중 안내를 더한다 (14단계 화면 1차)
- [조건] 패널(보낸 사람 · 받는 사람 · 제목 · 내용 · 기간 · 첨부)과 결과 머리의 조건 줄 — 조건도 화면 주소에 싣는다(since · until 은 브라우저 시간대 날짜 · 양 끝 포함) - 결과 줄 세 줄(분할 보기)과 맞은 말 노랑 강조 — HighlightedText 가 글 조각으로 그린다(v-html 없음), 읽기 화면은 서버가 넣은 <mark> · 제목 · 첨부 이름 강조. 강조가 든 응답은 보이기만 한다 (답장 · 전달 · 인쇄 · 예약은 강조 없이 받는다) - "검색 준비 중" 줄 · 한 글자 안내 · 빈 쪽이면 이어 찾기(한 번에 5번까지) + [더 찾기], total 이 null 이면 통 수를 숨긴다 - search-match.ts — backend search-query · search-text · subject-base 와 짝(같은 표본 표) 리뷰 반영: 이어 받는 동안 [다음] · [메일 더 보기] 기다림, 새로 고치는 동안 이어 받지 않음, 손으로 고친 주소도 패널과 같은 검사(8낱말 · 날짜 범위 · 거꾸로 된 기간), 패널 컴포넌트 나눔, 시험 정리(unmount · 보이지 않는 글자 이스케이프). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -181,6 +181,24 @@ describe('ComposePage', () => {
|
||||
expect(quote.text()).not.toContain('받는 사람')
|
||||
})
|
||||
|
||||
it('검색 결과에서 강조해 연 메일이 스토어에 있어도 답장의 원문은 검색 쿼리 없이 다시 받는다 — 인용에 <mark> 가 없다 (14단계)', async () => {
|
||||
const store = useMailStore()
|
||||
store.selectedId = 'm1'
|
||||
store.selected = {
|
||||
...ORIGINAL,
|
||||
htmlBody: '<p><mark>계약</mark>서를 보냅니다</p>',
|
||||
searchHighlight: { subject: [[0, 2]], attachments: {}, textBody: [] },
|
||||
}
|
||||
store.selectedHighlight = { q: '계약' }
|
||||
const { wrapper, requests } = await mountCompose('/mail/compose?replyTo=m1', replyApi)
|
||||
|
||||
const originals = requests.filter((request) => request.method === 'GET' && request.url === '/messages/m1')
|
||||
expect(originals).toHaveLength(1)
|
||||
expect(originals[0]?.params).toEqual({})
|
||||
expect(wrapper.get('#compose-body').html()).not.toContain('<mark')
|
||||
expect(wrapper.get('#compose-body').text()).toContain('김아라님, 안녕하세요.')
|
||||
})
|
||||
|
||||
it('답장은 커서를 본문 맨 위 첫 줄(인용 위)에 둔다', async () => {
|
||||
const { wrapper } = await mountCompose('/mail/compose?replyTo=m1', replyApi, true)
|
||||
await vi.waitFor(
|
||||
|
||||
@@ -8,6 +8,7 @@ import { routes } from '@/router/routes'
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import type { MessageDetail } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import { fail, flushPromises, installMockApi, ok, restoreApiAdapter, type MockRequest, type MockResult } from '@/test-utils/mock-api'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
@@ -168,6 +169,16 @@ describe('MailPrintPage', () => {
|
||||
expect(wrapper.get('iframe').attributes('srcdoc')).toContain('src="/api/mail-images/t0k"')
|
||||
})
|
||||
|
||||
it('검색 결과에서 강조해 연 메일도 인쇄는 검색 쿼리 없이 다시 받는다 — 인쇄 주소에도 싣지 않는다 (14단계)', async () => {
|
||||
const store = useMailStore()
|
||||
store.selected = { ...MESSAGE, htmlBody: '<p><mark>계약</mark></p>', searchHighlight: { subject: [], attachments: {}, textBody: [] } }
|
||||
store.selectedHighlight = { q: '계약' }
|
||||
const { wrapper, requests } = await mountAt('/mail/print/m1')
|
||||
|
||||
expect(requests[0]?.params).toEqual({})
|
||||
expect(wrapper.get('iframe').attributes('srcdoc')).not.toContain('<mark>')
|
||||
})
|
||||
|
||||
it('메일을 찾을 수 없으면 "메일을 찾을 수 없습니다" + [닫기] — 인쇄 창은 띄우지 않는다', async () => {
|
||||
const close = vi.spyOn(window, 'close').mockImplementation(() => undefined)
|
||||
const { wrapper, router } = await mountAt('/mail/print/m1', () => fail(404, 'RES_001'))
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import type { Mailbox, MessageDetail, MessageSummary } from '@/shared/types/mail'
|
||||
import { CLOCK } from '@/composables/useClock'
|
||||
import type { Mailbox, MessageDetail, MessageListItem, MessageSummary } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { mailViewModeStorageKey } from '@/stores/mail-view.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import { flushPromises, installMockApi, ok, restoreApiAdapter, type MockRequest } from '@/test-utils/mock-api'
|
||||
import { createManualClock } from '@/test-utils/manual-clock'
|
||||
import {
|
||||
fail,
|
||||
flushPromises,
|
||||
installMockApi,
|
||||
ok,
|
||||
restoreApiAdapter,
|
||||
type MockRequest,
|
||||
type MockResult,
|
||||
} from '@/test-utils/mock-api'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import MailboxPage from './MailboxPage.vue'
|
||||
|
||||
@@ -60,6 +70,8 @@ describe('MailboxPage — 검색 결과', () => {
|
||||
let pinia: Pinia
|
||||
let requests: MockRequest[]
|
||||
let results: MessageSummary[]
|
||||
/** 지난 메일 색인의 상태 (GET /search-index — 14단계) */
|
||||
let indexStatus: 'building' | 'ready'
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear()
|
||||
@@ -68,8 +80,10 @@ describe('MailboxPage — 검색 결과', () => {
|
||||
useAuthStore().markAuthenticated(USER)
|
||||
useMailStore().mailboxes = MAILBOXES
|
||||
results = [summary('r1'), summary('r2')]
|
||||
indexStatus = 'ready'
|
||||
requests = installMockApi(({ url }) => {
|
||||
if (url === '/messages') return ok({ items: results, pageInfo: { hasNext: false, nextCursor: null, total: results.length } })
|
||||
if (url === '/search-index') return ok({ status: indexStatus, done: 10, total: indexStatus === 'ready' ? 10 : 40 })
|
||||
return ok(detail(url.split('/').pop() ?? 'r1'))
|
||||
})
|
||||
})
|
||||
@@ -107,15 +121,16 @@ describe('MailboxPage — 검색 결과', () => {
|
||||
expect(wrapper.findAll('.message-row')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('결과가 없으면 한계 안내 한 줄과 [전체 메일함에서 찾기] — 누르면 전체에서 다시 찾는다(기록을 쌓지 않는다)', async () => {
|
||||
it('결과가 없으면 "다른 말로 찾거나 조건을 줄여 보세요."와 [전체 메일함에서 찾기] — 누르면 전체에서 다시 찾는다(기록을 쌓지 않는다)', async () => {
|
||||
results = []
|
||||
const { wrapper, router } = await mountAt(`/mail/search?q=${Q}&in=inbox`)
|
||||
const push = vi.spyOn(router, 'push')
|
||||
|
||||
expect(wrapper.text()).toContain('‘회의’에 맞는 메일이 없습니다')
|
||||
expect(wrapper.text()).toContain(
|
||||
'낱말 단위로 찾습니다. ‘서버이전’의 ‘서버’처럼 붙여 쓴 말의 일부나, 제목 맨 앞의 [공지]나 Re: 같은 말머리로는 찾지 못합니다.',
|
||||
)
|
||||
expect(wrapper.text()).toContain('다른 말로 찾거나 조건을 줄여 보세요.')
|
||||
// 색인이 준비됐으면 낱말 단위의 한계 안내 · 검색 준비 중 줄은 없다 (14단계)
|
||||
expect(wrapper.find('[data-search-word-limit]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-search-notice="indexing"]').exists()).toBe(false)
|
||||
await wrapper.get('.search-empty__all').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
@@ -206,3 +221,395 @@ describe('MailboxPage — 검색 결과', () => {
|
||||
expect(store.checkedCount).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('MailboxPage — 14단계 검색 (조건 · 강조 · 이어 찾기 · 검색 준비 중)', () => {
|
||||
let pinia: Pinia
|
||||
let requests: MockRequest[]
|
||||
let handler: (request: MockRequest) => MockResult | Promise<MockResult>
|
||||
/** 2026년 11월 6일 오전 9시(브라우저 시간대) */
|
||||
const clock = createManualClock(new Date(2026, 10, 6, 9).getTime())
|
||||
|
||||
const HIGHLIGHTED: MessageListItem = {
|
||||
...summary('r1', { subject: 'Re: 유지보수 계약 갱신 건', hasAttachment: true }),
|
||||
preview: '지난번 말씀하신 계약서 초안을 첨부했습니다.',
|
||||
searchHighlight: {
|
||||
subject: [[9, 11]],
|
||||
preview: { text: '…말씀하신 계약서 초안을 첨부했습니다.', ranges: [[6, 8]] },
|
||||
},
|
||||
}
|
||||
|
||||
function indexState(status: 'building' | 'ready'): MockResult {
|
||||
return ok({ status, done: 10, total: 40 })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear()
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().markAuthenticated(USER)
|
||||
useMailStore().mailboxes = MAILBOXES
|
||||
handler = ({ url }) => {
|
||||
if (url === '/search-index') return indexState('ready')
|
||||
if (url === '/messages') return ok({ items: [HIGHLIGHTED], pageInfo: { hasNext: false, nextCursor: null, total: 1 } })
|
||||
return ok(detail(url.split('/').pop() ?? 'r1'))
|
||||
}
|
||||
requests = installMockApi((request) => handler(request))
|
||||
})
|
||||
|
||||
/** 그린 화면 — 테스트마다 떼어 낸다 (초점을 보는 테스트는 문서에 붙여 그린다) */
|
||||
let mounted: VueWrapper[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const wrapper of mounted) wrapper.unmount()
|
||||
mounted = []
|
||||
vi.restoreAllMocks()
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
async function mountAt(path: string, options: { attach?: boolean } = {}) {
|
||||
const router = createTestRouter()
|
||||
await router.push(path)
|
||||
await router.isReady()
|
||||
const wrapper = mount(MailboxPage, {
|
||||
attachTo: options.attach ? document.body : undefined,
|
||||
global: { plugins: [pinia, router], provide: { [CLOCK as symbol]: clock } },
|
||||
})
|
||||
mounted.push(wrapper)
|
||||
await flushPromises()
|
||||
return { wrapper, router }
|
||||
}
|
||||
|
||||
function heldResult() {
|
||||
let resolve: (value: MockResult) => void = () => undefined
|
||||
const promise = new Promise<MockResult>((done) => {
|
||||
resolve = done
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
const listRequests = () => requests.filter((request) => request.url === '/messages')
|
||||
const CONDITIONS_QUERY = `from=${encodeURIComponent('이도윤')}&since=2026-10-06&until=2026-11-06&attachment=1`
|
||||
|
||||
it('주소의 조건으로 찾고 찾을 곳 줄 아래에 "조건: …" 줄 — [조건 지우기]는 조건만 빼고 다시 찾는다', async () => {
|
||||
const { wrapper, router } = await mountAt(`/mail/search?q=${Q}&in=inbox&${CONDITIONS_QUERY}`)
|
||||
|
||||
expect(listRequests()[0]?.params).toEqual({
|
||||
q: '회의',
|
||||
mailboxId: 'inbox',
|
||||
from: '이도윤',
|
||||
after: new Date(2026, 9, 6).toISOString(),
|
||||
before: new Date(2026, 10, 7).toISOString(),
|
||||
hasAttachment: 'true',
|
||||
limit: 20,
|
||||
})
|
||||
expect(wrapper.get('[data-search-condition-line]').text()).toContain(
|
||||
'조건: 이도윤 님이 보낸 메일, 최근 1달, 첨부 있음',
|
||||
)
|
||||
|
||||
await wrapper.get('[data-search-condition-line-clear]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe(`/mail/search?q=${Q}&in=inbox`)
|
||||
expect(wrapper.find('[data-search-condition-line]').exists()).toBe(false)
|
||||
expect(listRequests().at(-1)?.params).toEqual({ q: '회의', mailboxId: 'inbox', limit: 20 })
|
||||
})
|
||||
|
||||
it('조건만으로 찾으면 머리는 검색어 없이 수만 — 결과 없음은 "조건에 맞는 메일이 없습니다"', async () => {
|
||||
handler = ({ url }) =>
|
||||
url === '/search-index' ? indexState('ready') : ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })
|
||||
const { wrapper } = await mountAt(`/mail/search?in=inbox&${CONDITIONS_QUERY}`)
|
||||
|
||||
expect(wrapper.get('.search-header__count').text()).toBe('0통')
|
||||
expect(wrapper.text()).toContain('조건에 맞는 메일이 없습니다')
|
||||
expect(wrapper.text()).toContain('조건을 바꾸거나 줄여 보세요.')
|
||||
})
|
||||
|
||||
it('total 이 null 이면 통 수를 보이지 않는다', async () => {
|
||||
handler = ({ url }) =>
|
||||
url === '/search-index'
|
||||
? indexState('ready')
|
||||
: ok({ items: [HIGHLIGHTED], pageInfo: { hasNext: false, nextCursor: null, total: null } })
|
||||
const { wrapper } = await mountAt(`/mail/search?q=${Q}&in=all`)
|
||||
|
||||
expect(wrapper.get('.search-header__count').text()).toBe('‘회의’')
|
||||
})
|
||||
|
||||
it('결과 줄은 세 줄 — 보낸 사람 · 시각 / 제목(+ 첨부) / 미리 보기, 맞은 말은 <mark> (v-html 없이)', async () => {
|
||||
const { wrapper } = await mountAt(`/mail/search?q=${Q}&in=all`)
|
||||
|
||||
const row = wrapper.get('.message-row')
|
||||
expect(row.classes()).toContain('message-row--search')
|
||||
expect(row.get('.message-row__subject').text()).toBe('Re: 유지보수 계약 갱신 건')
|
||||
expect(row.get('.message-row__subject').findAll('mark').map((mark) => mark.text())).toEqual(['계약'])
|
||||
const preview = row.get('[data-row-preview]')
|
||||
expect(preview.text()).toBe('…말씀하신 계약서 초안을 첨부했습니다.')
|
||||
expect(preview.findAll('mark').map((mark) => mark.text())).toEqual(['계약'])
|
||||
expect(row.find('[aria-label="첨부 있음"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('강조가 없으면(엔진 방식 — 색인을 준비하는 중) 글 그대로 — 미리 보기는 메일의 미리 보기, 결과 머리 아래 "검색 준비 중" 줄', async () => {
|
||||
handler = ({ url }) => {
|
||||
if (url === '/search-index') return indexState('building')
|
||||
return ok({
|
||||
items: [{ ...HIGHLIGHTED, searchHighlight: null }],
|
||||
pageInfo: { hasNext: false, nextCursor: null, total: 1 },
|
||||
})
|
||||
}
|
||||
const { wrapper } = await mountAt(`/mail/search?q=${Q}&in=all`)
|
||||
|
||||
const row = wrapper.get('.message-row')
|
||||
expect(row.findAll('mark')).toHaveLength(0)
|
||||
expect(row.get('[data-row-preview]').text()).toBe('지난번 말씀하신 계약서 초안을 첨부했습니다.')
|
||||
const notice = wrapper.get('[data-search-notice="indexing"]')
|
||||
expect(notice.text()).toBe(
|
||||
'지난 메일을 검색할 수 있게 준비하고 있습니다. 준비가 끝날 때까지는 낱말이 통째로 같은 메일만 찾습니다.',
|
||||
)
|
||||
expect(notice.element.closest('[role="status"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('색인을 준비하는 중의 결과 없음에는 낱말 단위의 한계 안내를 더한다', async () => {
|
||||
handler = ({ url }) =>
|
||||
url === '/search-index' ? indexState('building') : ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })
|
||||
const { wrapper } = await mountAt(`/mail/search?q=${Q}&in=all`)
|
||||
|
||||
expect(wrapper.get('[data-search-word-limit]').text()).toBe(
|
||||
'낱말 단위로 찾습니다. ‘서버이전’의 ‘서버’처럼 붙여 쓴 말의 일부나, 제목 맨 앞의 [공지]나 Re: 같은 말머리로는 찾지 못합니다.',
|
||||
)
|
||||
})
|
||||
|
||||
it('색인 상태를 못 받으면 안내 줄 · 한계 안내 없이 지금처럼 — 알리지 않는다', async () => {
|
||||
handler = ({ url }) =>
|
||||
url === '/search-index' ? fail(503, 'SYS_001') : ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })
|
||||
const { wrapper } = await mountAt(`/mail/search?q=${Q}&in=all`)
|
||||
|
||||
expect(wrapper.find('[data-search-notice]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-search-word-limit]').exists()).toBe(false)
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
})
|
||||
|
||||
it('한 글자 낱말뿐이면(색인 방식) 결과 머리에 한 줄 — 본문은 찾지 않았다', async () => {
|
||||
const { wrapper } = await mountAt(`/mail/search?q=${encodeURIComponent('김 a')}&in=all`)
|
||||
|
||||
expect(wrapper.get('[data-search-notice="single-char"]').text()).toBe(
|
||||
'한 글자로만 찾을 때는 본문은 빼고 제목, 보낸 사람과 받는 사람, 첨부 파일 이름에서 찾습니다. 본문까지 찾으려면 두 글자 이상으로 찾아 주세요.',
|
||||
)
|
||||
|
||||
const longer = await mountAt(`/mail/search?q=${encodeURIComponent('김 계약')}&in=all`)
|
||||
expect(longer.wrapper.find('[data-search-notice="single-char"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('색인을 준비하는 중에는 한 글자 안내를 보이지 않는다 — 낱말 단위로 찾는 동안은 "검색 준비 중" 줄만', async () => {
|
||||
handler = ({ url }) =>
|
||||
url === '/search-index'
|
||||
? indexState('building')
|
||||
: ok({ items: [HIGHLIGHTED], pageInfo: { hasNext: false, nextCursor: null, total: 1 } })
|
||||
const { wrapper } = await mountAt(`/mail/search?q=${encodeURIComponent('김 a')}&in=all`)
|
||||
|
||||
expect(wrapper.find('[data-search-notice="single-char"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-search-notice="indexing"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('[더 찾기]는 찾는 동안 aria-busy · "찾는 중…"(다시 눌러도 묻지 않는다) — 찾아서 사라지면 초점을 목록 칸으로', async () => {
|
||||
let next = 0
|
||||
let hold: ReturnType<typeof heldResult> | null = null
|
||||
handler = ({ url }) => {
|
||||
if (url === '/search-index') return indexState('ready')
|
||||
if (url !== '/messages') return ok(detail('r1'))
|
||||
next += 1
|
||||
if (hold) return hold.promise
|
||||
return ok({ items: [], pageInfo: { hasNext: true, nextCursor: `c${next}`, total: null } })
|
||||
}
|
||||
const { wrapper } = await mountAt(`/mail/search?q=${Q}&in=all`, { attach: true })
|
||||
const more = wrapper.get('[data-search-more]')
|
||||
;(more.element as HTMLButtonElement).focus()
|
||||
|
||||
hold = heldResult()
|
||||
await more.trigger('click')
|
||||
await flushPromises()
|
||||
expect(more.attributes('aria-busy')).toBe('true')
|
||||
expect(more.attributes('aria-disabled')).toBe('true')
|
||||
expect(more.text()).toBe('찾는 중…')
|
||||
const sent = listRequests().length
|
||||
await more.trigger('click')
|
||||
await flushPromises()
|
||||
expect(listRequests().length).toBe(sent)
|
||||
|
||||
hold.resolve(ok({ items: [HIGHLIGHTED], pageInfo: { hasNext: false, nextCursor: null, total: null } }))
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('[data-search-more]').exists()).toBe(false)
|
||||
expect(wrapper.findAll('.message-row')).toHaveLength(1)
|
||||
expect(document.activeElement).toBe(wrapper.get('section.mail-list-frame').element)
|
||||
})
|
||||
|
||||
it('조건 줄의 [조건 지우기]를 누르면 줄이 사라진다 — 초점은 목록 칸으로', async () => {
|
||||
const { wrapper } = await mountAt(`/mail/search?q=${Q}&in=inbox&${CONDITIONS_QUERY}`, { attach: true })
|
||||
const clear = wrapper.get('[data-search-condition-line-clear]')
|
||||
;(clear.element as HTMLButtonElement).focus()
|
||||
|
||||
await clear.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('[data-search-condition-line]').exists()).toBe(false)
|
||||
expect(document.activeElement).toBe(wrapper.get('section.mail-list-frame').element)
|
||||
})
|
||||
|
||||
it('검색어와 조건의 낱말이 8개를 넘는 주소(손으로 고친 주소)는 찾지 않고 "찾을 말이 너무 많습니다" — 연 메일은 강조 없이 받는다', async () => {
|
||||
const words = encodeURIComponent('하나 둘 셋 넷 다섯')
|
||||
const body = encodeURIComponent('여섯 일곱 여덟 아홉')
|
||||
const { wrapper } = await mountAt(`/mail/search?q=${words}&in=inbox&body=${body}`)
|
||||
|
||||
expect(listRequests()).toEqual([])
|
||||
const empty = wrapper.get('[data-search-too-many-words]')
|
||||
expect(empty.text()).toContain('찾을 말이 너무 많습니다')
|
||||
expect(empty.text()).toContain(
|
||||
'검색어와 조건에 넣은 낱말은 모두 합쳐 8개까지 찾을 수 있습니다. 검색 칸이나 [조건]에서 낱말을 줄여 주세요.',
|
||||
)
|
||||
// 다른 메일함에서 찾아도 같다 — [전체 메일함에서 찾기]를 권하지 않는다. 조건은 조건 줄에서 지울 수 있다
|
||||
expect(wrapper.find('.search-empty__all').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-search-condition-line-clear]').exists()).toBe(true)
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
wrapper.unmount()
|
||||
mounted = []
|
||||
|
||||
// 그 주소로 연 메일은 강조 쿼리 없이 받는다 — 서버가 400 으로 거절해 메일을 열지 못하는 일이 없게
|
||||
const opened = await mountAt(`/mail/search/r1?q=${words}&in=inbox&body=${body}`)
|
||||
expect(requests.filter((request) => request.url === '/messages/r1').map((request) => request.params)).toEqual([{}])
|
||||
expect(opened.wrapper.find('#reader-subject').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('주소의 날짜가 거꾸로면 기간을 버리고, 고를 수 없는 날짜(1970-01-01 ~ 9999-12-30 밖)는 그 날짜만 버린다 — 나머지 조건으로 찾는다', async () => {
|
||||
const reversed = await mountAt(`/mail/search?q=${Q}&in=all&since=2026-11-06&until=2026-10-01&attachment=1`)
|
||||
expect(listRequests()[0]?.params).toEqual({ q: '회의', hasAttachment: 'true', limit: 20 })
|
||||
expect(reversed.wrapper.get('[data-search-condition-line]').text()).toContain('조건: 첨부 있음')
|
||||
reversed.wrapper.unmount()
|
||||
mounted = []
|
||||
requests.length = 0
|
||||
|
||||
await mountAt(`/mail/search?q=${Q}&in=all&since=1969-12-31&until=2026-11-01`)
|
||||
expect(listRequests()[0]?.params).toEqual({ q: '회의', before: new Date(2026, 10, 2).toISOString(), limit: 20 })
|
||||
for (const wrapper of mounted) wrapper.unmount()
|
||||
mounted = []
|
||||
requests.length = 0
|
||||
|
||||
const outside = await mountAt(`/mail/search?q=${Q}&in=all&since=2026-11-01&until=9999-12-31`)
|
||||
expect(listRequests()[0]?.params).toEqual({ q: '회의', after: new Date(2026, 10, 1).toISOString(), limit: 20 })
|
||||
expect(outside.wrapper.get('[data-search-condition-line]').text()).toContain('조건: 2026. 11. 1.부터')
|
||||
})
|
||||
|
||||
it('빈 쪽이 이어져 아직 못 찾았으면 결과 없음 대신 [더 찾기] — 누르면 이어 찾고, 더 없으면 결과 없음', async () => {
|
||||
let next = 0
|
||||
let exhausted = false
|
||||
handler = ({ url, params }) => {
|
||||
if (url === '/search-index') return indexState('ready')
|
||||
if (url !== '/messages') return ok(detail('r1'))
|
||||
next += 1
|
||||
const done = exhausted && params.cursor !== undefined
|
||||
return ok({ items: [], pageInfo: { hasNext: !done, nextCursor: done ? null : `c${next}`, total: null } })
|
||||
}
|
||||
const { wrapper } = await mountAt(`/mail/search?q=${Q}&in=all`)
|
||||
|
||||
expect(wrapper.text()).toContain('아직 맞는 메일을 찾지 못했습니다')
|
||||
expect(wrapper.text()).toContain('메일이 많아 한 번에 다 찾지 못했습니다. [더 찾기]를 누르면 이어서 찾습니다.')
|
||||
expect(wrapper.text()).not.toContain('에 맞는 메일이 없습니다')
|
||||
const sent = listRequests().length
|
||||
|
||||
exhausted = true
|
||||
await wrapper.get('[data-search-more]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(listRequests().length).toBe(sent + 1)
|
||||
expect(wrapper.find('[data-search-more]').exists()).toBe(false)
|
||||
expect(wrapper.text()).toContain('‘회의’에 맞는 메일이 없습니다')
|
||||
})
|
||||
|
||||
it('결과에서 연 메일은 검색어와 글 조건 넷만 넘겨 강조를 받는다 — 제목 · 첨부 이름 · 글 본문의 맞은 말', async () => {
|
||||
handler = ({ url, params }) => {
|
||||
if (url === '/search-index') return indexState('ready')
|
||||
if (url === '/messages') return ok({ items: [HIGHLIGHTED], pageInfo: { hasNext: false, nextCursor: null, total: 1 } })
|
||||
const highlighted = params.q !== undefined
|
||||
return ok({
|
||||
...detail('r1'),
|
||||
subject: 'Re: 유지보수 계약 갱신 건',
|
||||
htmlBody: null,
|
||||
textBody: '계약서 초안을 첨부했습니다.',
|
||||
attachments: [{ id: 'a1', name: '유지보수_계약서.docx', type: 'application/msword', size: 100, isInline: false }],
|
||||
searchHighlight: highlighted ? { subject: [[9, 11]], attachments: { a1: [[5, 7]] }, textBody: [[0, 2]] } : null,
|
||||
})
|
||||
}
|
||||
const { wrapper, router } = await mountAt(`/mail/search/r1?q=${Q}&in=inbox&${CONDITIONS_QUERY}&subject=${encodeURIComponent('갱신')}`)
|
||||
|
||||
const detailRequest = requests.find((request) => request.url === '/messages/r1')
|
||||
expect(detailRequest?.params).toEqual({ q: '회의', from: '이도윤', subject: '갱신' })
|
||||
expect(wrapper.get('#reader-subject').findAll('mark').map((mark) => mark.text())).toEqual(['계약'])
|
||||
expect(wrapper.get('.attachments__name').findAll('mark').map((mark) => mark.text())).toEqual(['계약'])
|
||||
expect(wrapper.get('iframe').attributes('srcdoc')).toContain('<mark>계약</mark>서 초안을 첨부했습니다.')
|
||||
|
||||
// 같은 메일을 메일함에서 열면 강조가 든 본문을 쓰지 않고 쿼리 없이 다시 받는다
|
||||
await router.push('/mail/inbox/r1')
|
||||
await flushPromises()
|
||||
const plain = requests.filter((request) => request.url === '/messages/r1').at(-1)
|
||||
expect(plain?.params).toEqual({})
|
||||
expect(wrapper.get('#reader-subject').findAll('mark')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('MailboxPage — 14단계 검색 · 목록 보기', () => {
|
||||
let pinia: Pinia
|
||||
let total: number | null
|
||||
|
||||
const HIGHLIGHTED: MessageListItem = {
|
||||
...summary('r1', { subject: '견적서와 계약 조건 정리' }),
|
||||
searchHighlight: { subject: [[5, 7]], preview: { text: '', ranges: [] } },
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear()
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().markAuthenticated(USER)
|
||||
useMailStore().mailboxes = MAILBOXES
|
||||
window.localStorage.setItem(mailViewModeStorageKey(USER.email), 'list')
|
||||
total = 1
|
||||
installMockApi(({ url }) => {
|
||||
if (url === '/search-index') return ok({ status: 'ready', done: 1, total: 1 })
|
||||
if (url === '/messages') {
|
||||
return ok({ items: [HIGHLIGHTED, summary('r2')], pageInfo: { hasNext: false, nextCursor: null, total } })
|
||||
}
|
||||
return ok(detail(url.split('/').pop() ?? 'r1'))
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
})
|
||||
|
||||
async function mountAt(path: string) {
|
||||
const router = createTestRouter()
|
||||
await router.push(path)
|
||||
await router.isReady()
|
||||
const wrapper = mount(MailboxPage, { global: { plugins: [pinia, router] } })
|
||||
await flushPromises()
|
||||
return { wrapper, router }
|
||||
}
|
||||
|
||||
it('표의 제목에도 맞은 말을 <mark> 로 — 행 높이는 그대로(미리 보기 줄은 분할 보기만)', async () => {
|
||||
const { wrapper } = await mountAt(`/mail/search?q=${Q}&in=all`)
|
||||
|
||||
const link = wrapper.get('tbody .mail-table__link')
|
||||
expect(link.text()).toBe('견적서와 계약 조건 정리')
|
||||
expect(link.findAll('mark').map((mark) => mark.text())).toEqual(['계약'])
|
||||
expect(wrapper.find('[data-row-preview]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('total 이 null 이면 연 메일의 위치 "n / 전체"를 보이지 않는다 — 이전 · 다음은 그대로', async () => {
|
||||
total = null
|
||||
const { wrapper } = await mountAt(`/mail/search/r1?q=${Q}&in=all`)
|
||||
|
||||
expect(wrapper.find('.nav-position').exists()).toBe(false)
|
||||
expect(wrapper.get('button[aria-label="다음 메일"]').attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -18,7 +18,8 @@ import MessageReader from '@/components/mail/MessageReader.vue'
|
||||
import MessageTable from '@/components/mail/MessageTable.vue'
|
||||
import ScheduledMessageList from '@/components/mail/ScheduledMessageList.vue'
|
||||
import { useMailSearchNavigation } from '@/composables/useMailSearchNavigation'
|
||||
import { isScheduledRouteName } from '@/shared/utils/mail-list-source'
|
||||
import { isScheduledRouteName, isSearchTooLong, listKeyOf } from '@/shared/utils/mail-list-source'
|
||||
import { messageHighlightKey, messageHighlightQueryOf } from '@/shared/utils/mail-search'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { useMailViewStore } from '@/stores/mail-view.store'
|
||||
|
||||
@@ -57,25 +58,37 @@ watch(
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// 검색어 · 범위가 바뀔 때만 다시 찾는다 — 결과에서 메일을 열고 닫는 것으로는 다시 찾지 않는다.
|
||||
// 검색어가 없는 주소(/mail/search)는 검색을 닫은 것으로 본다
|
||||
// 검색어 · 범위 · 조건(14단계)이 바뀔 때만 다시 찾는다 — 결과에서 메일을 열고 닫는 것으로는 다시 찾지 않는다.
|
||||
// 검색어도 조건도 없는 주소(/mail/search)는 검색을 닫은 것으로 본다
|
||||
watch(
|
||||
[isSearching, () => routeSearch.value?.q ?? null, () => routeSearch.value?.scope ?? null],
|
||||
([searching, q, scope]) => {
|
||||
[isSearching, () => (routeSearch.value ? listKeyOf(routeSearch.value) : null)],
|
||||
([searching, key]) => {
|
||||
if (!searching) return
|
||||
if (q === null || scope === null) {
|
||||
const search = routeSearch.value
|
||||
if (key === null || !search) {
|
||||
void closeSearch()
|
||||
return
|
||||
}
|
||||
void mailStore.openSearch(q, scope)
|
||||
void mailStore.openSearch(search.q, search.scope, { conditions: search.conditions })
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
/**
|
||||
* 검색 결과에서 연 메일의 강조 쿼리 (14단계 — 검색어와 글 조건 넷만). 메일함에서 연 메일은 null —
|
||||
* 쿼리가 바뀌면 같은 메일이어도 다시 받는다(검색 결과의 강조 본문이 메일함 화면에 남지 않게).
|
||||
* 낱말이 한도를 넘는 주소(손으로 고친 주소)면 싣지 않는다 — 서버가 400 으로 거절해 메일을 열지 못한다
|
||||
*/
|
||||
const highlightQuery = computed(() =>
|
||||
isSearching.value && routeSearch.value && !isSearchTooLong(routeSearch.value)
|
||||
? messageHighlightQueryOf(routeSearch.value.q, routeSearch.value.conditions)
|
||||
: null,
|
||||
)
|
||||
|
||||
watch(
|
||||
messageId,
|
||||
(id) => {
|
||||
if (id) void mailStore.openMessage(id)
|
||||
[messageId, () => messageHighlightKey(highlightQuery.value)],
|
||||
([id]) => {
|
||||
if (id) void mailStore.openMessage(id, highlightQuery.value)
|
||||
else mailStore.closeMessage()
|
||||
},
|
||||
{ immediate: true },
|
||||
|
||||
Reference in New Issue
Block a user