generated from ttipo/project-base
feat(frontend): 읽기 화면에 같은 대화 칸을 더한다 (14단계 화면 2차)
- 본문 · 첨부 아래 "같은 대화 N통" — 오래된 차례의 줄(48), 지금 보는 메일 줄은 누를 수 없고, 다른 줄은 그 자리에서 펼쳐 본문(MailBodyFrame — 강조 쿼리 없이)과 [답장] · [이 메일만 열기] - 100통씩 받고 쪽이 모자라면 5번까지 이어 묻기 + [더 보기], 통 수를 모르면 수 없이 - 실패는 칸 안 안내 + [다시 시도](알림 없음), 이 경로가 없는 옛 서버(404)면 칸이 없다 - 다시 받기는 메일함 통 수가 바뀔 때 · 쓰기 화면에서 돌아올 때 · 붙잡힌 메일이 풀릴 때 — 읽음 표시만 바뀐 소식으로는 받지 않고, 끝난 때부터 10초 간격 - 펼친 안 읽은 메일은 읽음으로(읽기 화면과 같은 길), 펼친 본문 안 Ctrl+P 는 그 메일 인쇄 리뷰 반영: 누른 단추가 사라질 때 초점 옮기기, 읽음 소식으로 요청이 두 배가 되던 것, 받는 중 온 소식 · 시계 뒤로 · [더 보기] 뒤 붙잡힌 메일, 받아 둔 본문 10통까지, 본문 안내 줄 · 지워진 메일, 목록 보기에서는 [이 메일만 열기]가 기록을 바꿔 끼운다. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
import { h } from 'vue'
|
||||
import { createMemoryHistory, createRouter, RouterView } from 'vue-router'
|
||||
|
||||
import { CLOCK } from '@/composables/useClock'
|
||||
import { routes } from '@/router/routes'
|
||||
import { MAIL_STATE_POLL_INTERVAL_MS } from '@/shared/constants/mail'
|
||||
import { MAIL_CONVERSATION_REFRESH_GAP_MS } from '@/shared/constants/mail-conversation'
|
||||
import type { Mailbox, MessageDetail, MessageSummary } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import { createManualClock, type ManualClock } from '@/test-utils/manual-clock'
|
||||
import { flushPromises, installMockApi, ok, restoreApiAdapter, type MockRequest } from '@/test-utils/mock-api'
|
||||
|
||||
// 메일 화면의 바뀜 소식(1분 확인 · 실시간 — MailLayout 의 onChange) → 메일함 개수 맞추기 → 같은 대화 칸 다시 받기 (14단계).
|
||||
// 읽음 · 중요 표시만 바뀐 소식(메일함들의 통 수가 그대로)으로는 대화를 다시 받지 않는다 — 대화 경로의 요청 수 한도를 아끼려고(리뷰 중간 2).
|
||||
// 실제 메일 화면(MailLayout → MailboxPage → 읽기 화면 → 같은 대화 칸)을 띄우고 손으로 돌리는 시계로 1분 확인을 넘긴다
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.test', name: '김아라' }
|
||||
|
||||
function mailbox(id: string, role: Mailbox['role'], name: string, totalCount: number, unreadCount: number): Mailbox {
|
||||
return { id, name, role, parentId: null, sortOrder: 0, totalCount, unreadCount }
|
||||
}
|
||||
|
||||
function summary(id: string, partial: Partial<MessageSummary> = {}): MessageSummary {
|
||||
return {
|
||||
id,
|
||||
threadId: 't1',
|
||||
mailboxIds: ['inbox'],
|
||||
from: { name: '이도윤', email: 'doyun.lee@arasystem.test' },
|
||||
to: [{ name: '김아라', email: USER.email }],
|
||||
subject: '회신: 유지보수 계약 갱신 건',
|
||||
preview: `미리 보기 ${id}`,
|
||||
receivedAt: new Date(2026, 10, 5, 9, 30).toISOString(),
|
||||
isRead: true,
|
||||
isFlagged: false,
|
||||
hasAttachment: false,
|
||||
size: 100,
|
||||
pendingSend: null,
|
||||
...partial,
|
||||
}
|
||||
}
|
||||
|
||||
function detail(base: MessageSummary): MessageDetail {
|
||||
return {
|
||||
...base,
|
||||
cc: [],
|
||||
bcc: [],
|
||||
replyTo: [],
|
||||
sentAt: null,
|
||||
messageId: null,
|
||||
references: [],
|
||||
htmlBody: null,
|
||||
textBody: `${base.id} 본문`,
|
||||
hasBlockedImages: false,
|
||||
attachments: [],
|
||||
}
|
||||
}
|
||||
|
||||
const page = (items: unknown[], total = items.length) => ok({ items, pageInfo: { hasNext: false, nextCursor: null, total } })
|
||||
|
||||
describe('MailLayout — 바뀜 소식으로 같은 대화 칸을 다시 받는다 (메일함들의 통 수가 바뀔 때만)', () => {
|
||||
let pinia: Pinia
|
||||
let clock: ManualClock
|
||||
let requests: MockRequest[]
|
||||
let state: string
|
||||
let inboxTotal: number
|
||||
let inboxUnread: number
|
||||
let thread: MessageSummary[]
|
||||
const mounted: VueWrapper[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear()
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().markAuthenticated(USER)
|
||||
clock = createManualClock(new Date(2026, 10, 6, 15).getTime())
|
||||
state = 's1'
|
||||
inboxTotal = 2
|
||||
inboxUnread = 1
|
||||
thread = [summary('m1', { mailboxIds: ['sent'], from: USER }), summary('m3')]
|
||||
requests = installMockApi(({ url, method }) => {
|
||||
if (url === '/mail-state') return ok({ state })
|
||||
if (url === '/mailboxes') {
|
||||
return page([mailbox('inbox', 'inbox', '받은편지함', inboxTotal, inboxUnread), mailbox('sent', 'sent', '보낸편지함', 1, 0)])
|
||||
}
|
||||
if (url === '/mail-quota') {
|
||||
return ok({ usedBytes: 0, limitBytes: null, attachmentMaxBytes: 1, attachmentTotalMaxBytes: 1 })
|
||||
}
|
||||
if (url === '/mail-signature') {
|
||||
return ok({ bodyHtml: null, body: null, isForNew: true, isForReply: true, images: [], updatedAt: null })
|
||||
}
|
||||
if (url === '/mail-vacation') {
|
||||
return ok({ isEnabled: false, fromDate: null, toDate: null, subject: '', textBody: '', isActive: true })
|
||||
}
|
||||
if (url === '/mail-blocks') return ok({ senders: [], isActive: true })
|
||||
if (url === '/mail-settings') return ok({ undoSendSeconds: 10, externalImages: 'ask', groupByConversation: false })
|
||||
if (url === '/scheduled-messages') return page([])
|
||||
if (url === '/messages' && method === 'GET') return page([summary('m3'), summary('m2', { threadId: 't2' })])
|
||||
if (url.endsWith('/conversation')) return ok({ items: thread, pageInfo: { hasNext: false, nextCursor: null, total: thread.length }, basis: 'index' })
|
||||
if (url === '/messages/m3') return ok(detail(summary('m3')))
|
||||
return ok(null)
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const wrapper of mounted.splice(0)) {
|
||||
if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
|
||||
}
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
document.body.innerHTML = ''
|
||||
document.title = ''
|
||||
})
|
||||
|
||||
async function settle() {
|
||||
for (let round = 0; round < 4; round += 1) await flushPromises()
|
||||
}
|
||||
|
||||
async function mountMail() {
|
||||
const router = createRouter({ history: createMemoryHistory(), routes })
|
||||
await router.push('/mail/inbox/m3')
|
||||
await router.isReady()
|
||||
const wrapper = mount(
|
||||
{ render: () => h(RouterView) },
|
||||
{ attachTo: document.body, global: { plugins: [pinia, router], provide: { [CLOCK as symbol]: clock } } },
|
||||
)
|
||||
mounted.push(wrapper)
|
||||
await settle()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
const conversationRequests = () => requests.filter((request) => request.url.endsWith('/conversation'))
|
||||
const mailboxRequests = () => requests.filter((request) => request.url === '/mailboxes')
|
||||
|
||||
/** 1분 확인을 넘긴다 — 바뀐 state 면 메일 화면이 메일함 개수 · 보이는 목록을 맞춘다(onChange) */
|
||||
async function nextPoll(nextState: string) {
|
||||
state = nextState
|
||||
clock.advance(MAIL_STATE_POLL_INTERVAL_MS)
|
||||
await settle()
|
||||
}
|
||||
|
||||
it('읽음 표시만 바뀐 소식(통 수 그대로)으로는 다시 받지 않고, 새 메일로 통 수가 바뀌면 다시 받는다', async () => {
|
||||
const wrapper = await mountMail()
|
||||
expect(wrapper.find('[data-conversation]').exists()).toBe(true)
|
||||
expect(conversationRequests()).toHaveLength(1)
|
||||
|
||||
// 다른 곳에서 메일 한 통을 읽었다 — 안 읽은 수만 바뀐다
|
||||
const mailboxLoads = mailboxRequests().length
|
||||
inboxUnread = 0
|
||||
await nextPoll('s2')
|
||||
expect(mailboxRequests().length).toBeGreaterThan(mailboxLoads)
|
||||
clock.advance(MAIL_CONVERSATION_REFRESH_GAP_MS)
|
||||
await settle()
|
||||
expect(conversationRequests()).toHaveLength(1)
|
||||
|
||||
// 같은 대화에 새 메일이 왔다 — 받은편지함의 통 수가 바뀐다
|
||||
inboxTotal = 3
|
||||
thread = [...thread, summary('m4', { receivedAt: new Date(2026, 10, 6, 15, 2).toISOString() })]
|
||||
await nextPoll('s3')
|
||||
expect(conversationRequests()).toHaveLength(2)
|
||||
expect(conversationRequests()[1]?.config.silent).toBe(true)
|
||||
expect(wrapper.get('[data-conversation] h3').text()).toBe('같은 대화 3통')
|
||||
})
|
||||
|
||||
it(`받기가 끝난 지 ${MAIL_CONVERSATION_REFRESH_GAP_MS}ms 안에 온 소식은 그때까지 모았다가 한 번 받는다`, async () => {
|
||||
await mountMail()
|
||||
expect(conversationRequests()).toHaveLength(1)
|
||||
|
||||
// 처음 받기가 끝난 바로 뒤 — 실시간 소식처럼 곧바로 메일함 개수를 맞춘 것으로 본다
|
||||
inboxTotal = 3
|
||||
useMailStore().syncCounts()
|
||||
await settle()
|
||||
expect(conversationRequests()).toHaveLength(1)
|
||||
|
||||
clock.advance(MAIL_CONVERSATION_REFRESH_GAP_MS)
|
||||
await settle()
|
||||
expect(conversationRequests()).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
@@ -57,6 +57,7 @@ useMailStatePolling({
|
||||
void mailStore.loadScheduledCount()
|
||||
// 열린 메일의 대용량 첨부가 보안 검사 중이면 상태를 다시 받는다 (11단계 — 끝나면 받을 수 있다)
|
||||
void mailStore.refreshOpenedLargeFiles()
|
||||
// 읽기 화면의 "같은 대화" 칸은 위 syncCounts 로 받은 메일함들의 통 수가 바뀌었을 때만 다시 받는다 (14단계 — mail.store.ts 의 mailboxTotalsKey)
|
||||
},
|
||||
onHiddenChange: (inboxUnread) => mailStore.setInboxUnread(inboxUnread),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user