fix(frontend): 14단계 화면을 실사용 시험 · 사용자 관점 점검대로 고친다

- 분할 보기에서 대화 줄을 고르면 도구 줄이 두 줄로 넘치던 것, 목록 머리의
  안 읽은 수가 1440 에서도 늘 잘리던 것
- 묶은 줄에 한 일의 알림은 "{메일함}에 있는 메일 N통을 …"(줄의 통 수는
  대화 전체라서), 되돌리기 알림은 되돌린 메일함을 말한다
- 같은 대화 칸: 펼친 줄의 첨부 수 안내, 우리 원문 인용 머리부터 뺀 미리
  보기, 보내기를 기다리는 메일은 줄 끝에
- 검색 준비 중 · 결과 없음 · 하루짜리 기간 · 내 이름 조건의 글, 예약 시각의
  띄어쓰기, 이름 맞추기(대화로 묶기 · 내용에 든 말 · 표 머리 참여자)
- 다른 탭 · PC 에서 바꾼 설정을 탭으로 돌아올 때 다시 받는다(60초 지났을 때)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 01:15:43 +09:00
parent 0090a161ec
commit 9ba4bc1a41
39 changed files with 1024 additions and 93 deletions
@@ -322,7 +322,7 @@ describe('ComposePage — 보내기 취소 · 예약 발송 (9단계)', () => {
expect(wrapper.find('#compose-recipient-error').exists()).toBe(true)
})
it('고르기 첫 줄로 [예약] — 발송 본문에 sendAt(UTC) · 알림 "‘시각’ 에 보냅니다." + [예약됨 보기] · 예약한 메일 수를 다시 묻는다', async () => {
it('고르기 첫 줄로 [예약] — 발송 본문에 sendAt(UTC) · 알림 "‘시각’에 보냅니다." + [예약됨 보기] · 예약한 메일 수를 다시 묻는다', async () => {
const preset = schedulePresets(NOW)[0]
if (!preset) throw new Error('고르기가 없다')
const sendAt = sendAtOf(preset.at)
@@ -344,7 +344,7 @@ describe('ComposePage — 보내기 취소 · 예약 발송 (9단계)', () => {
expect(toasts()).toEqual([
expect.objectContaining({
kind: 'success',
message: `${formatScheduleTime(sendAt)} 에 보냅니다.`,
message: `${formatScheduleTime(sendAt)}에 보냅니다.`,
action: { label: '예약됨 보기', to: { name: 'mail-scheduled' } },
expiresAt: null,
}),
+6 -2
View File
@@ -8,7 +8,8 @@
// 들어올 때 예약한 메일 수(패널의 「예약됨」) · 차단 목록(읽기 화면 ⋯ 의 차단 · 스팸메일함의 안내 줄)을 받아 둔다 (9단계 — 못 받으면 알리지 않는다).
// 예약한 메일 수는 새 메일 확인 · 실시간 알림이 바뀜을 알릴 때 다시 묻는다 (계약 2절 "보내기 취소 · 예약 발송").
// 엔진이 붙잡은 발송을 풀 때(보낼 시각)는 새 메일 확인이 알아채지 못한다 — 그 시각이 지나면 보이는 목록 · 열린 메일 · 예약됨 수를 다시 본다
// (usePendingSendRefresh). 들어올 때 보내기 · 읽기 설정도 받아 둔다 — 쓰기 화면의 [보내기 취소] 알림이 설정한 초로 마감을 센다(못 받으면 알리지 않는다)
// (usePendingSendRefresh). 들어올 때 보내기 · 읽기 설정도 받아 둔다 — 쓰기 화면의 [보내기 취소] 알림이 설정한 초로 마감을 센다(못 받으면 알리지 않는다).
// 탭이 다시 보일 때 마지막으로 받은 지 60초가 지났으면 설정을 조용히 다시 받는다 — 다른 탭에서 바꾼 묶기가 남지 않게 (useMailSettingsRefresh, 14단계)
// 회사가 2단계 인증을 필수로 정한 유예 기간이면 상단 바 바로 아래에 노란 줄 (13단계 — TwoFactorGraceNotice)
import { provide } from 'vue'
@@ -19,6 +20,7 @@ import MailboxPanel from '@/components/mail/MailboxPanel.vue'
import TwoFactorGraceNotice from '@/components/mail/TwoFactorGraceNotice.vue'
import { useClock } from '@/composables/useClock'
import { createMailContactDialog, MAIL_CONTACT_DIALOG } from '@/composables/useMailContactDialog'
import { useMailSettingsRefresh } from '@/composables/useMailSettingsRefresh'
import { useMailStatePolling } from '@/composables/useMailStatePolling'
import { useMailTabTitle } from '@/composables/useMailTabTitle'
import { usePendingSendRefresh } from '@/composables/usePendingSendRefresh'
@@ -44,8 +46,10 @@ void useMailVacationStore().load()
void mailStore.loadScheduledCount()
// 읽기 화면 ⋯ 「보낸 사람 차단」 · 「차단 풀기」와 스팸메일함의 "차단한 보낸 사람의 메일" 줄
useMailBlockStore().ensureLoaded()
// 쓰기 화면의 [보내기 취소] 알림 마감 — 설정한 초 (모르면 서버가 준 보낼 시각으로 센다)
// 쓰기 화면의 [보내기 취소] 알림 마감 — 설정한 초 (모르면 서버가 준 보낼 시각으로 센다). 메일함 목록의 묶기도 이 설정이다
useMailSettingsStore().ensureLoaded()
// 탭이 다시 보일 때 — 설정을 마지막으로 받은 지 60초가 지났으면 조용히 다시 받는다 (다른 탭 · PC 에서 바꾼 값)
useMailSettingsRefresh({ clock })
// 새 메일 확인 — 바뀌었으면 메일함 개수 · 사용량과 보이는 메일함 목록을 맞춘다 (검색 결과는 저절로 바꾸지 않는다 — 스토어가 거른다).
// 숨은 탭은 받은편지함의 안 읽은 수만 고쳐 탭 제목 "(N) " 을 맞추고, 다시 보일 때 맞춘다
@@ -162,7 +162,7 @@ describe('예약됨 · 보낸편지함 (9단계)', () => {
const opened = await mountAt('/mail/scheduled/s1')
expect(opened.wrapper.find('.scheduled-row').exists()).toBe(false)
expect(opened.wrapper.get('[data-notice="scheduled"]').text()).toContain(`${formatScheduleTime(FIRST_SEND_AT)} 에 보낼 메일입니다.`)
expect(opened.wrapper.get('[data-notice="scheduled"]').text()).toContain(`${formatScheduleTime(FIRST_SEND_AT)}에 보낼 메일입니다.`)
expect(opened.wrapper.get('.reader').text()).toContain('목록으로')
})
@@ -121,13 +121,15 @@ 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('다른 말로 찾거나 조건을 줄여 보세요.')
// 걸린 조건이 없으면 조건을 줄이라고 하지 않는다 (2026-11-06 실사용 시험)
expect(wrapper.text()).toContain('다른 말로 찾아 보세요.')
expect(wrapper.text()).not.toContain('조건을 줄여')
// 색인이 준비됐으면 낱말 단위의 한계 안내 · 검색 준비 중 줄은 없다 (14단계)
expect(wrapper.find('[data-search-word-limit]').exists()).toBe(false)
expect(wrapper.find('[data-search-notice="indexing"]').exists()).toBe(false)
@@ -326,6 +328,24 @@ describe('MailboxPage — 14단계 검색 (조건 · 강조 · 이어 찾기 ·
expect(wrapper.text()).toContain('조건을 바꾸거나 줄여 보세요.')
})
it('검색어와 조건으로 찾아 결과가 없으면 "다른 말로 찾거나 조건을 줄여 보세요."', async () => {
handler = ({ url }) =>
url === '/search-index' ? indexState('ready') : ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })
const { wrapper } = await mountAt(`/mail/search?q=${Q}&in=inbox&${CONDITIONS_QUERY}`)
expect(wrapper.text()).toContain('‘회의’에 맞는 메일이 없습니다')
expect(wrapper.text()).toContain('다른 말로 찾거나 조건을 줄여 보세요.')
})
it('조건 줄 — 보낸 사람 · 받는 사람이 내 이름이나 주소면 "내가 보낸 메일" · "내가 받은 메일", 하루만 고른 기간은 "… 하루"', async () => {
const mine = `from=${encodeURIComponent('김아라')}&to=${encodeURIComponent(USER.email.toUpperCase())}&since=2026-11-06&until=2026-11-06`
const { wrapper } = await mountAt(`/mail/search?q=${Q}&in=inbox&${mine}`)
expect(wrapper.get('[data-search-condition-line]').text()).toContain(
'조건: 내가 보낸 메일, 내가 받은 메일, 2026. 11. 6. 하루',
)
})
it('total 이 null 이면 통 수를 보이지 않는다', async () => {
handler = ({ url }) =>
url === '/search-index'
@@ -363,20 +383,22 @@ describe('MailboxPage — 14단계 검색 (조건 · 강조 · 이어 찾기 ·
expect(row.findAll('mark')).toHaveLength(0)
expect(row.get('[data-row-preview]').text()).toBe('지난번 말씀하신 계약서 초안을 첨부했습니다.')
const notice = wrapper.get('[data-search-notice="indexing"]')
// 색인은 밤에만 채운다 — 낮 내내 같은 글이어도 멈춘 줄 알지 않게 (2026-11-06 실사용 시험)
expect(notice.text()).toBe(
'지난 메일을 검색할 수 있게 준비하고 있습니다. 준비가 끝날 때까지는 낱말이 통째로 같은 메일만 찾습니다.',
'지난 메일을 검색할 수 있게 밤사이에 준비하고 있습니다. 메일이 많으면 며칠 걸릴 수 있고, 그동안은 낱말이 통째로 같은 메일만 찾습니다.',
)
expect(notice.element.closest('[role="status"]')).not.toBeNull()
})
it('색인을 준비하는 중의 결과 없음에는 낱말 단위의 한계 안내를 더한다', async () => {
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: 같은 말머리로는 찾지 못합니다.',
'준비가 끝나기 전에는 ‘서버이전’의 ‘서버’처럼 붙여 쓴 말의 일부나, 제목 맨 앞의 [공지]나 Re: 같은 말머리로는 찾지 못합니다.',
)
expect(wrapper.get('[data-search-word-limit]').text()).not.toContain('통째로')
})
it('색인 상태를 못 받으면 안내 줄 · 한계 안내 없이 지금처럼 — 알리지 않는다', async () => {
@@ -7,6 +7,7 @@ import { createMemoryHistory, createRouter, RouterView, type Router } from 'vue-
import { CLOCK } from '@/composables/useClock'
import { routes } from '@/router/routes'
import { MAIL_STATE_POLL_INTERVAL_MS } from '@/shared/constants/mail'
import { MAIL_SETTINGS_REFRESH_AFTER_MS } from '@/shared/constants/mail-settings'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import type {
ConversationSummary,
@@ -214,6 +215,17 @@ describe('MailboxPage — 대화로 묶어 보기 (14단계)', () => {
const threadRequests = () => requests.filter((request) => request.url === '/conversations')
const messageListRequests = () => requests.filter((request) => request.url === '/messages' && request.method === 'GET')
/** 목록 머리의 개수 칸 — 보이는 글 · 화면 낭독 글 · title (분할 보기는 줄인 글을 보인다) */
const headCount = (wrapper: VueWrapper) => {
const count = wrapper.get('.list-header__count')
const shown = count.find('[aria-hidden="true"]')
const spoken = count.find('.visually-hidden')
return {
shown: shown.exists() ? shown.text() : count.text(),
spoken: spoken.exists() ? spoken.text() : count.text(),
title: count.attributes('title'),
}
}
const rowTexts = (wrapper: VueWrapper) =>
wrapper.findAll('.thread-row').map((row) => ({
who: row.get('.thread-row__who').text(),
@@ -235,7 +247,12 @@ describe('MailboxPage — 대화로 묶어 보기 (14단계)', () => {
{ who: '이도윤, 나', count: '2통', subject: 'Re: 유지보수 계약 갱신 건', unread: false },
])
expect(wrapper.findAll('.thread-row')[0]?.find('[aria-label="첨부 있음"]').exists()).toBe(true)
expect(wrapper.get('.list-header__count').text()).toBe('대화 6개, 안 읽은 메일 2통')
// 분할 보기(좁은 머리)는 안 읽은 메일이 있으면 그 수만 보이고 전체 글은 title · 화면 낭독에 (2026-11-06 실사용 시험)
expect(headCount(wrapper)).toEqual({
shown: '안 읽은 메일 2통',
spoken: '대화 6개, 안 읽은 메일 2통',
title: '대화 6개, 안 읽은 메일 2통',
})
const toggle = wrapper.get('[data-group-toggle]')
expect(toggle.attributes('aria-pressed')).toBe('true')
// 분할 보기의 목록 칸은 아이콘만 — 이름은 title · 화면 낭독
@@ -274,7 +291,11 @@ describe('MailboxPage — 대화로 묶어 보기 (14단계)', () => {
expect(messageListRequests()).toHaveLength(1)
expect(wrapper.findAll('.thread-row')).toHaveLength(0)
expect(wrapper.findAll('.message-row')).toHaveLength(2)
expect(wrapper.get('.list-header__count').text()).toBe('메일 9통, 안 읽은 메일 2통')
expect(headCount(wrapper)).toEqual({
shown: '안 읽은 메일 2통',
spoken: '메일 9통, 안 읽은 메일 2통',
title: '메일 9통, 안 읽은 메일 2통',
})
expect(wrapper.get('[data-group-toggle]').attributes('aria-pressed')).toBe('false')
await wrapper.get('[data-group-toggle]').trigger('click')
@@ -324,12 +345,25 @@ describe('MailboxPage — 대화로 묶어 보기 (14단계)', () => {
expect(requests.filter((request) => request.method === 'PATCH')).toEqual([])
})
it('고르면 도구 줄 "대화 1개 선택됨" — 하는 일은 그 줄의 메일 모두', async () => {
it('고르면 도구 줄 "대화 1개 선택됨" — 분할 보기는 "1개 선택됨"만 보이고 화면 낭독은 전체, 하는 일은 그 줄의 메일 모두', async () => {
rememberGrouped()
const { wrapper } = await mountMail()
await wrapper.findAll('.thread-row')[0]?.get('[role="checkbox"]').trigger('click')
expect(wrapper.get('.bulk-bar__count').text()).toBe('대화 1개 선택됨')
// 단추 일곱과 한 줄에 들게 보이는 글만 줄인다 (2026-11-06 실사용 시험 — "대화 1개 선택됨"이면 두 줄로 넘쳤다)
const count = wrapper.get('.bulk-bar__count')
expect(count.get('[aria-hidden="true"]').text()).toBe('1개 선택됨')
expect(count.get('.visually-hidden').text()).toBe('대화 1개 선택됨')
expect(count.attributes('id')).toBe(wrapper.get('.mail-list-frame').attributes('aria-labelledby'))
// 단추 차례는 그대로
expect(wrapper.findAll('[role="toolbar"] [data-tool]').map((tool) => tool.attributes('data-tool'))).toEqual([
'markRead',
'markUnread',
'flag',
'move',
'delete',
'clear',
])
await wrapper.get('[data-tool="markRead"]').trigger('click')
await settle()
@@ -339,6 +373,29 @@ describe('MailboxPage — 대화로 묶어 보기 (14단계)', () => {
expect(wrapper.findAll('.thread-row')[0]?.classes()).not.toContain('is-unread')
})
it('줄에 한 삭제의 알림은 "받은편지함에 있는 메일 N통을 휴지통으로 …" — 줄의 통 수는 대화 전체라서, 되돌리면 "메일 N통을 받은편지함으로 되돌렸습니다."', async () => {
rememberGrouped()
const { wrapper } = await mountMail()
await wrapper.findAll('.thread-row')[0]?.get('[role="checkbox"]').trigger('click')
await wrapper.get('[data-tool="delete"]').trigger('click')
await settle()
expect(requests.filter((request) => request.method === 'PATCH' && request.url === '/messages').map((request) => request.data)).toEqual([
{ ids: ['m6', 'm5'], mailboxId: 'trash' },
])
const [toast] = useToastStore().toasts
expect(toast?.message).toBe('받은편지함에 있는 메일 2통을 휴지통으로 옮겼습니다.')
if (toast?.action && 'run' in toast.action) toast.action.run()
await settle()
expect(requests.filter((request) => request.method === 'PATCH' && request.url === '/messages').at(-1)?.data).toEqual({
ids: ['m6', 'm5'],
mailboxId: 'inbox',
})
expect(useToastStore().toasts.map((item) => item.message)).toContain('메일 2통을 받은편지함으로 되돌렸습니다.')
})
it('휴지통에서 고른 대화를 영구 삭제 — 확인 창의 수는 메일 수이고 그 줄의 메일을 POST /message-deletions', async () => {
rememberGrouped()
rows = [conversation('t1', ['m8', 'm7'], { latest: summary('m8', { mailboxIds: ['trash'] }) }), conversation('t2', ['m1'])]
@@ -365,6 +422,12 @@ describe('MailboxPage — 대화로 묶어 보기 (14단계)', () => {
const heads = wrapper.findAll('table.mail-table thead th').map((th) => th.text())
expect(heads).not.toContain('크기')
// 사람 칸 머리는 어느 메일함이든 "참여자" — 줄이 대화의 참여자다 (2026-11-06 실사용 시험)
expect(heads).toContain('참여자')
expect(heads).not.toContain('보낸 사람')
// 목록 보기(넓은 머리)는 개수 글을 다 보인다
expect(wrapper.get('.list-header__count').text()).toBe('대화 6개, 안 읽은 메일 2통')
expect(wrapper.find('.list-header__count .visually-hidden').exists()).toBe(false)
expect(wrapper.get('thead th [role="checkbox"]').attributes('aria-label')).toBe('불러온 대화 모두 고르기')
expect(wrapper.get('table.mail-table caption').text()).toBe('받은편지함 대화 목록')
expect(wrapper.findAll('tbody tr').map((row) => row.get('[data-message-id]').attributes('data-message-id'))).toEqual([
@@ -400,6 +463,31 @@ describe('MailboxPage — 대화로 묶어 보기 (14단계)', () => {
expect(wrapper.findAll('.thread-row').map((row) => row.attributes('data-row-id'))).toEqual(['c9', 'c1', 'c2', 'c3'])
})
it('다른 탭에서 끈 묶기 — 60초 뒤 탭으로 돌아오면 설정을 조용히 다시 받아 메일 목록으로 다시 연다 (2026-11-06 실사용 시험)', async () => {
rememberGrouped()
const { wrapper } = await mountMail()
expect(wrapper.findAll('.thread-row')).toHaveLength(3)
const settingsRequests = () => requests.filter((request) => request.url === '/mail-settings' && request.method === 'GET')
expect(settingsRequests()).toHaveLength(1)
// 다른 탭이 껐다
settings = { ...settings, groupByConversation: false }
clock.advance(MAIL_SETTINGS_REFRESH_AFTER_MS - 1)
document.dispatchEvent(new Event('visibilitychange'))
await settle()
expect(settingsRequests()).toHaveLength(1)
clock.advance(1)
document.dispatchEvent(new Event('visibilitychange'))
await settle()
expect(settingsRequests()).toHaveLength(2)
expect(settingsRequests()[1]?.config.silent).toBe(true)
expect(wrapper.findAll('.thread-row')).toHaveLength(0)
expect(wrapper.findAll('.message-row')).toHaveLength(2)
expect(wrapper.get('[data-group-toggle]').attributes('aria-pressed')).toBe('false')
expect(useToastStore().toasts).toEqual([])
})
describe('세션이 끝나면 목록을 부르지 않는다 (리뷰 M1)', () => {
it.each([
['로그아웃', () => useAuthStore().clearAuth()],
@@ -118,18 +118,18 @@ describe('MailSendReadPage', () => {
expect(wrapper.get('[data-settings-status]').text()).toBe('저장했습니다')
})
it('대화로 묶어 보기(14단계) — 끔 · 켬 전환과 설명, 고르면 그 칸만 PATCH 하고 "저장했습니다"', async () => {
it('대화로 묶기(14단계 — 목록 위 단추와 같은 이름) — 끔 · 켬 전환과 설명, 고르면 그 칸만 PATCH 하고 "저장했습니다"', async () => {
const wrapper = await mountPage()
const grouping = () =>
wrapper.findAll('[data-group-by-conversation]').map((button) => [button.text(), button.attributes('aria-pressed')])
expect(wrapper.get('#send-read-group-title').text()).toBe('대화로 묶어 보기')
expect(wrapper.get('#send-read-group-title').text()).toBe('대화로 묶기')
expect(grouping()).toEqual([
['끔', 'true'],
['켬', 'false'],
])
expect(wrapper.get('#send-read-group-hint').text()).toBe(
'답장과 전달로 이어진 메일을 메일함 목록에서 한 줄로 묶어 보여 줍니다. 목록 위의 [대화로 묶기]로도 켜고 끌 수 있습니다.',
'답장과 전달로 이어진 메일을 메일함 목록에서 한 줄로 묶어 보여 줍니다. 메일함 목록 위의 말풍선 모양 [대화로 묶기] 단추로도 켜고 끌 수 있습니다.',
)
await wrapper.get('[data-group-by-conversation="true"]').trigger('click')
@@ -4,7 +4,7 @@
// 머리 "보내기 · 읽기" + 설명 + 오른쪽에 "저장했습니다"(role=status — 저장 단추 없음) →
// 「보내기 취소」 전환(36 — .segmented 밀도 예외: 끔 · 5초 · 10초 · 20초 · 30초) + 설명 → 가는 선 →
// 「외부 이미지」 라디오 셋(늘 묻기 · 주소록 · 사내 직원의 메일은 늘 보기 · 늘 보기 — 줄마다 아래 설명) + 회색 안내 상자 → 가는 선 →
// 「대화로 묶어 보기」 전환(36 — 끔 · 켬, 14단계 — 목록 머리의 [대화로 묶기]와 같은 설정) + 설명.
// 「대화로 묶기」 전환(36 — 끔 · 켬, 14단계 — 목록 머리의 [대화로 묶기]와 같은 설정 · 같은 이름) + 설명.
// - 들어올 때마다 새로 받는다(다른 PC 에서 바꿨을 수 있다 — 서버에 저장되어 어느 PC 에서나 같다). 받는 동안은 자리만, 못 받으면 사전 문구와 "다시 시도"
// - 고르는 대로 그 칸만 PATCH 한다(mail-settings.store — 화면을 먼저 바꾸고 실패하면 고르기 전 값으로 되돌린다, 알림은 인터셉터).
// 저장이 끝나면 "저장했습니다", 다시 고르면 내린다