fix(frontend): 화면 문구 75곳을 사람이 말하듯 다듬는다

2026-11-07 사용자가 "자연스러운 문구가 아니라 시스템, 설계 문구 형식"이라고 짚은
글을 고친다(규칙 9 · 10). "여기에 표시됩니다 · 보입니다 · 나타납니다"처럼 화면을
주어로 한 설명과 "맞는 메일 · 분류된 · 지원하지 않습니다 · 처리" 같은 번역투 · 설계
말을, 내 메일이 어떻게 되는지와 무엇을 하면 되는지로 바꾼다. 정할 것 여섯은 추천안
(읽기 칸이 비었을 때 "고른 메일이 없습니다", 필터 칸 이름 [받은편지함에 이미 있는
메일도 정리하기], 본문 안내에 원문 내려받기까지, 로그인 소개 글)이다. 같은 대화
칸의 펼친 줄은 본문 안내의 앞 문장만 둔다(그 칸의 ⋯ 는 위에 연 메일의 것이다).
사전 글을 견주던 시험은 사전을 참조하게 바꿨다. 시험 2738 → 2762.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 14:37:57 +09:00
parent c9e6858b2a
commit 1bd4b3d626
68 changed files with 283 additions and 211 deletions
@@ -5,7 +5,7 @@ import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { CLOCK } from '@/composables/useClock'
import { PASSWORD_CHANGE_TIMEOUT_MS } from '@/shared/constants/account'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import { RESTRICTED_SECRET_WINDOW_MS } from '@/shared/constants/session-restriction'
import { RESTRICTED_SECRET_WINDOW_MS, SUPPORT_CONTACT_TEXT } from '@/shared/constants/session-restriction'
import type { TwoFactorEnrollment, TwoFactorStatus } from '@/shared/types/account-security'
import type { SessionRestriction } from '@/shared/types/auth'
import { useAuthStore } from '@/stores/auth.store'
@@ -602,7 +602,7 @@ describe('LoginPage — 제한된 세션 (13단계)', () => {
it('정하지 않았거나 받지 못하면 지금 글 그대로 — 알리지 않는다', async () => {
const { wrapper } = await mountLogin({ supportContact: () => fail(503, 'SYS_001') })
expect(wrapper.get('[data-support-line]').text()).toBe('비밀번호를 잊으셨다면 시스템 관리자에게 문의해 주세요.')
expect(wrapper.get('[data-support-line]').text()).toBe(SUPPORT_CONTACT_TEXT.passwordFallback)
expect(useToastStore().toasts).toEqual([])
})
})
+1 -1
View File
@@ -376,7 +376,7 @@ void supportContactStore.ensureLoaded()
사내 업무를<br>한 계정으로
</p>
<p class="login__lead">
메일부터 시작해 캘린더와 파일 저장소까지 같은 계정으로 이어집니다.
메일부터 시작해 캘린더와 파일 저장소까지 같은 계정으로 쓸 수 있게 됩니다.
</p>
</div>
</section>
+1 -1
View File
@@ -8,7 +8,7 @@
role="alert"
>
<h1>404</h1>
<p>요청하신 정보나 페이지를 찾을 수 없습니다.</p>
<p>찾는 페이지가 없습니다. 주소가 맞는지 확인해 주세요.</p>
<RouterLink to="/">
홈으로 돌아가기
</RouterLink>
@@ -5,7 +5,12 @@ import { h, type FunctionalComponent } from 'vue'
import { createMemoryHistory, createRouter, RouterView, type Router } from 'vue-router'
import { CLOCK } from '@/composables/useClock'
import { CONTACTS_SEARCH_DELAY_MS } from '@/shared/constants/contacts'
import {
CONTACT_DETAIL_TEXT,
CONTACTS_EMPTY_TEXT,
CONTACTS_LIST_TEXT,
CONTACTS_SEARCH_DELAY_MS,
} from '@/shared/constants/contacts'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import type {
Contact,
@@ -387,7 +392,7 @@ describe('ContactsPage (주소록 모듈)', () => {
const row = app.get('[data-contacts-row="c2"]')
expect(row.get('.contacts-row__meta').text()).toBe('(주)한빛상사 · 영업팀')
expect(row.get('.contacts-row__email').text()).toBe('jihoon.park@hanbit.example')
expect(app.get('[data-contact-none]').text()).toContain('연락처를 골라 주세요')
expect(app.get('[data-contact-none]').text()).toContain(CONTACT_DETAIL_TEXT.noneTitle)
await click(row.get('a'))
expect(router.currentRoute.value.fullPath).toBe('/contacts/all/c2')
@@ -624,11 +629,11 @@ describe('ContactsPage (주소록 모듈)', () => {
const { app } = await mountApp('/contacts/directory/people/s5')
expect(app.get('#contacts-list-title').text()).toBe('직원')
expect(app.get('.contacts-list__note').text()).toBe('회사 메일을 쓰는 직원이라 이름과 메일 주소만 보입니다.')
expect(app.get('.contacts-list__note').text()).toBe(CONTACTS_LIST_TEXT.directoryNote.person)
const detail = app.get('[data-directory-detail="s5"]')
expect(detail.get('.contact-detail__subtitle').text()).toBe('사내 직원')
expect(detail.findAll('.tool-button').map((button) => button.text())).toEqual(['메일 쓰기', '내 주소록에 추가'])
expect(detail.get('.contact-detail__hint').text()).toContain('사내 목록은 이름과 주소만 보이고 여기서 고칠 수 없습니다')
expect(detail.get('.contact-detail__hint').text()).toContain(CONTACT_DETAIL_TEXT.directoryHint)
await click(detail.get('[data-contact-add-mine]'))
expect(isOpen(app, '#contact-dialog')).toBe(true)
@@ -720,7 +725,7 @@ describe('ContactsPage (주소록 모듈)', () => {
clock.advance(CONTACTS_SEARCH_DELAY_MS)
await flushPromises()
const empty = app.get('[data-contacts-empty="search"]')
expect(empty.text()).toContain('‘없는사람’에 맞는 연락처가 없습니다')
expect(empty.text()).toContain(CONTACTS_EMPTY_TEXT.searchTitle('없는사람', false))
expect(empty.text()).toContain('이름, 주소, 회사, 전화번호의 일부나 이름의 초성으로 찾을 수 있습니다.')
// Esc 는 찾을 글을 지운다 — 목록이 다시 그룹 전체로
+1 -1
View File
@@ -1195,7 +1195,7 @@ watch(hasMissingLarge, (missing) => {
v-model="to"
label="받는 사람"
input-id="compose-to"
placeholder="메일 주소 입력 후 Enter"
placeholder="메일 주소를 쓰고 Enter를 눌러 주세요"
:error-id="RECIPIENT_ERROR_ID"
:rejected="rejectedByServer !== null"
:disabled="preparing"
@@ -3,6 +3,8 @@ import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { CLOCK } from '@/composables/useClock'
import { MAIL_SEARCH_TEXT } from '@/shared/constants/mail'
import { SEARCH_NOTICE_TEXT } from '@/shared/constants/mail-search'
import type { Mailbox, MessageDetail, MessageListItem, MessageSummary } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
@@ -128,7 +130,7 @@ describe('MailboxPage — 검색 결과', () => {
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(MAIL_SEARCH_TEXT.emptyTitle('회의'))
// 걸린 조건이 없으면 조건을 줄이라고 하지 않는다 (2026-11-06 실사용 시험)
expect(wrapper.text()).toContain('다른 말로 찾아 보세요.')
expect(wrapper.text()).not.toContain('조건을 줄여')
@@ -335,7 +337,7 @@ describe('MailboxPage — 14단계 검색 (조건 · 강조 · 이어 찾기 ·
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(MAIL_SEARCH_TEXT.emptyTitle('회의'))
expect(wrapper.text()).toContain('다른 말로 찾거나 조건을 줄여 보세요.')
})
@@ -402,9 +404,7 @@ describe('MailboxPage — 14단계 검색 (조건 · 강조 · 이어 찾기 ·
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.text()).toBe(SEARCH_NOTICE_TEXT.indexing)
expect(notice.element.closest('[role="status"]')).not.toBeNull()
})
@@ -416,7 +416,7 @@ describe('MailboxPage — 14단계 검색 (조건 · 강조 · 이어 찾기 ·
expect(wrapper.get('[data-search-word-limit]').text()).toBe(
'준비가 끝나기 전에는 ‘서버이전’의 ‘서버’처럼 붙여 쓴 말의 일부나, 제목 맨 앞의 [공지]나 Re: 같은 말머리로는 찾지 못합니다.',
)
expect(wrapper.get('[data-search-word-limit]').text()).not.toContain('통째로')
expect(wrapper.get('[data-search-word-limit]').text()).not.toContain('끝까지 다 써야')
})
it('색인 상태를 못 받으면 안내 줄 · 한계 안내 없이 지금처럼 — 알리지 않는다', async () => {
@@ -514,9 +514,7 @@ describe('MailboxPage — 14단계 검색 (조건 · 강조 · 이어 찾기 ·
expect(listRequests()).toEqual([])
const empty = wrapper.get('[data-search-too-many-words]')
expect(empty.text()).toContain('찾을 말이 너무 많습니다')
expect(empty.text()).toContain(
'검색어와 조건에 넣은 낱말은 모두 합쳐 8개까지 찾을 수 있습니다. 검색 칸이나 [조건]에서 낱말을 줄여 주세요.',
)
expect(empty.text()).toContain(SEARCH_NOTICE_TEXT.tooManyWordsHint(8))
// 다른 메일함에서 찾아도 같다 — [전체 메일함에서 찾기]를 권하지 않는다. 조건은 조건 줄에서 지울 수 있다
expect(wrapper.find('.search-empty__all').exists()).toBe(false)
expect(wrapper.find('[data-search-condition-line-clear]').exists()).toBe(true)
@@ -561,9 +559,9 @@ describe('MailboxPage — 14단계 검색 (조건 · 강조 · 이어 찾기 ·
}
const { wrapper } = await mountAt(`/mail/search?q=${Q}&in=all`)
expect(wrapper.text()).toContain('아직 맞는 메일을 찾지 못했습니다')
expect(wrapper.text()).toContain(SEARCH_NOTICE_TEXT.partialTitle)
expect(wrapper.text()).toContain('메일이 많아 한 번에 다 찾지 못했습니다. [더 찾기]를 누르면 이어서 찾습니다.')
expect(wrapper.text()).not.toContain('에 맞는 메일이 없습니다')
expect(wrapper.text()).not.toContain(MAIL_SEARCH_TEXT.emptyTitle('회의'))
const sent = listRequests().length
exhausted = true
@@ -572,7 +570,7 @@ describe('MailboxPage — 14단계 검색 (조건 · 강조 · 이어 찾기 ·
expect(listRequests().length).toBe(sent + 1)
expect(wrapper.find('[data-search-more]').exists()).toBe(false)
expect(wrapper.text()).toContain('‘회의’에 맞는 메일이 없습니다')
expect(wrapper.text()).toContain(MAIL_SEARCH_TEXT.emptyTitle('회의'))
})
it('결과에서 연 메일은 검색어와 글 조건 넷만 넘겨 강조를 받는다 — 제목 · 첨부 이름 · 글 본문의 맞은 말', async () => {
@@ -3,6 +3,7 @@ import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { CLOCK } from '@/composables/useClock'
import { APP_PASSWORD_RESULT_TEXT } from '@/shared/constants/account-security'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import type { AppPassword, CreatedAppPassword } from '@/shared/types/account-security'
import { formatBlockedDay, formatRelativeDayTime } from '@/shared/utils/format'
@@ -252,7 +253,7 @@ describe('AccountAppPasswordsPage', () => {
'uk0n',
])
expect(wrapper.get('[data-app-password-value]').text()).toBe(VALUE)
expect(wrapper.get('.result__length').text()).toBe('40자입니다. 보기 좋게 4자씩 띄웠지만 띄어쓰기는 없습니다.')
expect(wrapper.get('.result__length').text()).toBe(APP_PASSWORD_RESULT_TEXT.lengthNote('40'))
expect(document.activeElement?.hasAttribute('data-app-password-copy')).toBe(true)
expect(wrapper.get('.result__warning').text()).toContain('이 창을 닫으면 다시 볼 수 없습니다.')
@@ -3,6 +3,7 @@ import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { CLOCK } from '@/composables/useClock'
import { LOGIN_HISTORY_TEXT } from '@/shared/constants/account-security'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import type { AuthSession, LoginEvent } from '@/shared/types/account-security'
import { formatRelativeDayTime } from '@/shared/utils/format'
@@ -16,6 +17,8 @@ import AccountLoginHistoryPage from './AccountLoginHistoryPage.vue'
// 설정 › 계정 › 로그인 기록 (12단계 — 디자인 원본 SettingsLoginHistory · 계약 5절 "로그인된 브라우저" · "로그인 기록")
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
/** 로그인된 브라우저 아래 안내 — 가운데 [앱 비밀번호]가 링크다 */
const SESSIONS_NOTE = `${LOGIN_HISTORY_TEXT.sessionsNoteBefore}${LOGIN_HISTORY_TEXT.sessionsNoteLink}${LOGIN_HISTORY_TEXT.sessionsNoteAfter}`
/**
* 2026년 그 시각을 이 PC 의 시간대(지역 시각)로 — 화면은 시각을 브라우저 시간대로 보인다.
* "오늘 오전 9:05" 같은 글을 그대로 보려면 시각을 지역 시각으로 적어야 어느 시간대에서도 같다
@@ -193,7 +196,7 @@ describe('AccountLoginHistoryPage', () => {
])
expect(wrapper.get('[data-sessions-end-others]').text()).toBe('다른 곳 모두 로그아웃')
const note = wrapper.get('[data-sessions-note]')
expect(note.text()).toBe('웹메일 로그인만 보입니다. 메일 프로그램의 연결을 끊으려면 앱 비밀번호를 지워 주세요.')
expect(note.text()).toBe(SESSIONS_NOTE)
expect(note.get('a').attributes('href')).toBe('/settings/account/app-passwords')
})
@@ -253,9 +256,7 @@ describe('AccountLoginHistoryPage', () => {
expect(dialog?.hasAttribute('open')).toBe(false)
expect(sessionRows(wrapper).map((row) => row[0])).toEqual(['Chrome · Windows'])
expect(wrapper.find('[data-sessions-end-others]').exists()).toBe(false)
expect(wrapper.get('[data-sessions-note]').text()).toBe(
'다른 곳에 로그인된 브라우저가 없습니다. 웹메일 로그인만 보입니다. 메일 프로그램의 연결을 끊으려면 앱 비밀번호를 지워 주세요.',
)
expect(wrapper.get('[data-sessions-note]').text()).toBe(`${LOGIN_HISTORY_TEXT.noOthers}${SESSIONS_NOTE}`)
expect(document.activeElement?.id).toBe('login-sessions-title')
})
@@ -398,7 +399,7 @@ describe('AccountLoginHistoryPage', () => {
},
})
expect(wrapper.get('[data-events-more]').text()).toBe('더 보기')
expect(wrapper.text()).toContain('최근 순서로 20개씩 보입니다. 90일이 지난 기록은 지워집니다.')
expect(wrapper.text()).toContain(LOGIN_HISTORY_TEXT.moreHint)
await wrapper.get('[data-events-filter="failures"]').trigger('click')
await flushPromises()
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { MAIL_CLIENTS_TEXT } from '@/shared/constants/mail-clients'
import type { TwoFactorStatus } from '@/shared/types/account-security'
import { useAuthStore } from '@/stores/auth.store'
import { useToastStore } from '@/stores/toast.store'
@@ -91,9 +92,7 @@ describe('AccountMailClientsPage', () => {
it('프로그램별 차례 — 새 Outlook 주의 줄 · 다섯 프로그램, 처음에는 모두 접힘', async () => {
const wrapper = await mountPage()
expect(wrapper.get('[data-mail-clients-cloud-warning]').text()).toBe(
'새 Outlook과 휴대폰 Outlook 앱은 메일을 Microsoft 클라우드를 거쳐 받습니다. 회사 정책에 따라 쓰지 않는 것이 좋습니다.',
)
expect(wrapper.get('[data-mail-clients-cloud-warning]').text()).toBe(MAIL_CLIENTS_TEXT.cloudWarning)
const buttons = wrapper.findAll('[data-mail-client] h3 button')
expect(buttons.map((button) => button.text())).toEqual([
expect.stringContaining('아웃룩 (Windows)'),
@@ -3,6 +3,7 @@ import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import { MAIL_BLOCK_TEXT } from '@/shared/constants/mail-block'
import type { BlockedSender } from '@/shared/types/mail'
import { formatBlockedDay } from '@/shared/utils/format'
import { useAuthStore } from '@/stores/auth.store'
@@ -247,7 +248,7 @@ describe('MailBlocksPage', () => {
})
describe('엔진에서 우리 규칙이 꺼져 있으면(isActive: false) 머리 아래 노란 줄 — 단추 없음', () => {
const INACTIVE = '다른 곳에서 메일 설정이 바뀌어 지금은 차단이 되지 않습니다. 주소를 차단하거나 풀면 다시 켜집니다.'
const INACTIVE = MAIL_BLOCK_TEXT.inactive
it('차단한 주소가 있을 때만 보인다(role=status) — 켜져 있거나 비었으면 없다', async () => {
const off = await mountPage({ isActive: false })
@@ -6,6 +6,7 @@ import { RouterView } from 'vue-router'
import MessageReader from '@/components/mail/MessageReader.vue'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import { MAIL_FILTER_APPLY_TEXT, MAIL_FILTER_TEXT } from '@/shared/constants/mail-filter'
import type {
Mailbox,
MailFilterRule,
@@ -246,16 +247,16 @@ describe('MailFilterPage', () => {
expect(row(wrapper, 'r5').get('[data-filter-down]').attributes('aria-disabled')).toBe('true')
expect(wrapper.get('[data-filter-count]').text()).toBe('필터 5개를 만들었고, 50개까지 만들 수 있습니다. 줄을 누르면 고칠 수 있습니다.')
expect(wrapper.get('[data-filter-notes]').findAll('p').map((note) => note.text())).toEqual([
'·위에 있는 필터부터 차례로 메일을 정리합니다. 메일함으로 옮기는 필터에 맞은 메일은 그 아래 필터를 거치지 않습니다. 순서는 ▲ ▼ 로 바꿀 수 있습니다.',
'·이미 받은 메일도 정리하려면 필터를 저장할 때 [받은편지함에 이미 있는 메일에도 적용]을 골라 주세요. 받은편지함에 있는 메일만 그 필터 하나로 정리합니다.',
'·스팸으로 분류된 메일은 필터를 거치지 않고 그대로 스팸메일함으로 갑니다.',
'·전달은 새로 오는 메일만 사내 주소로 합니다. 메일 한 통은 한 곳으로만 전달되고, 전달하는 필터가 여럿이면 위에 있는 필터가 먼저입니다. 전달해도 내 메일함에 그대로 남습니다.',
'·필터가 메일을 옮겨도 부재중 자동 응답은 그대로 보냅니다.',
`·${MAIL_FILTER_TEXT.noteOrderStrong}${MAIL_FILTER_TEXT.noteOrderAfter}`,
`·${MAIL_FILTER_TEXT.noteApplyBefore}${MAIL_FILTER_TEXT.noteApplyStrong}${MAIL_FILTER_TEXT.noteApplyAfter}`,
`·${MAIL_FILTER_TEXT.noteSpam}`,
`·${MAIL_FILTER_TEXT.noteForward}`,
`·${MAIL_FILTER_TEXT.noteVacation}`,
])
expect(wrapper.get('[data-filter-note-apply] strong').text()).toBe('[받은편지함에 이미 있는 메일에도 적용]')
expect(wrapper.get('.settings-page__description').text()).toBe(
'받은 메일을 조건에 따라 메일함으로 옮기거나, 읽음이나 중요로 표시하거나, 동료에게 전달합니다. 새로 오는 메일을 정리하고, 저장할 때 고르면 받은편지함에 이미 있는 메일도 함께 정리합니다.',
)
// 안내 상자의 [ ]는 고치기 창의 칸 이름 그대로다 — 칸 이름을 바꾸면 함께 바뀐다
expect(wrapper.get('[data-filter-note-apply] strong').text()).toBe(`[${MAIL_FILTER_APPLY_TEXT.label}]`)
expect(MAIL_FILTER_TEXT.noteApplyStrong).toBe(`[${MAIL_FILTER_APPLY_TEXT.label}]`)
expect(wrapper.get('.settings-page__description').text()).toBe(MAIL_FILTER_TEXT.description)
expect(wrapper.find('[data-filter-empty]').exists()).toBe(false)
expect(wrapper.find('[data-filter-conflict]').exists()).toBe(false)
expect(wrapper.find('[data-filter-inactive]').exists()).toBe(false)
@@ -542,7 +543,7 @@ describe('MailFilterPage', () => {
expect(wrapper.get('#mail-filter-error').text()).toBe(ERROR_CODE_MESSAGES.MAIL_012)
const bar = wrapper.get('[data-filter-conflict]')
expect(bar.attributes('role')).toBe('alert')
expect(bar.text()).toContain('다른 곳에서 필터가 바뀌어 저장하지 않았습니다. 다시 불러온 뒤 고쳐 주세요.')
expect(bar.text()).toContain(ERROR_CODE_MESSAGES.MAIL_012)
expect(toasts()).toEqual([])
filters = { rules: [RULES[2] as MailFilterRule], version: 'v9', isActive: true }
@@ -721,7 +722,7 @@ describe('MailFilterPage', () => {
const { wrapper } = await mountPage({ filters: { ...SET, isActive: false } })
const bar = wrapper.get('[data-filter-inactive]')
expect(bar.text()).toContain('다른 곳에서 메일 설정이 바뀌어 지금은 필터와 부재중 자동 응답이 되지 않습니다.')
expect(bar.text()).toContain(MAIL_FILTER_TEXT.inactive)
await click(bar.get('button'))
expect(puts()).toEqual([{ rules: RULES, version: 'v1' }])
@@ -814,7 +815,7 @@ describe('MailFilterPage', () => {
expect(router.currentRoute.value.fullPath).toBe('/settings/mail/filters')
})
describe('받은편지함에 이미 있는 메일에도 적용 (7단계 — 디자인 원본 MailFilterApply · MailFilterApplyStates)', () => {
describe('받은편지함에 이미 있는 메일도 정리하기 (7단계 — 디자인 원본 MailFilterApply · MailFilterApplyStates)', () => {
const applyBox = (wrapper: VueWrapper) => wrapper.get('#mail-filter-apply')
const applyInput = (wrapper: VueWrapper) => applyBox(wrapper).element as HTMLInputElement
const applyHelp = (wrapper: VueWrapper) => wrapper.get('#mail-filter-apply-help').text()
@@ -829,9 +830,9 @@ describe('MailFilterPage', () => {
expect(applyInput(wrapper).checked).toBe(false)
expect(applyBox(wrapper).attributes('disabled')).toBeUndefined()
expect(applyBox(wrapper).attributes('aria-describedby')).toBe('mail-filter-apply-help')
expect(wrapper.get('[data-filter-apply] label').text()).toBe('받은편지함에 이미 있는 메일에도 적용')
expect(wrapper.get('[data-filter-apply] label').text()).toBe(MAIL_FILTER_APPLY_TEXT.label)
expect(applyHelp(wrapper)).toBe(HELP)
expect(wrapper.get('[data-filter-apply-spam]').text()).toBe('스팸으로 분류된 메일은 필터를 거치지 않습니다.')
expect(wrapper.get('[data-filter-apply-spam]').text()).toBe(MAIL_FILTER_APPLY_TEXT.spam)
// 6단계의 안내 띠는 없다
expect(wrapper.text()).not.toContain('새로 오는 메일에만 적용됩니다')
@@ -12,7 +12,7 @@
// - 읽기 화면 ⋯ 「이런 메일로 필터 만들기」는 ?from=<보낸 사람 주소> 로 온다 — 받은 뒤 새 필터 창을 그 주소로 채워 열고(50개면 열지 않는다)
// 주소에서 지운다(replace — 새로 고침에 또 열리지 않게)
// - 누른 단추가 사라져 초점을 잃으면(지운 행 · 사라진 줄 · 빈 상태의 [새 필터]) 가까운 행이나 머리의 [새 필터]로 옮긴다
// - 창의 「받은편지함에 이미 있는 메일에도 적용」(7단계)을 고른 채 저장하면 저장한 뒤 받은편지함에 적용한다(useMailFilterActions → useMailFilterRun).
// - 창의 「받은편지함에 이미 있는 메일도 정리하기」(7단계)를 고른 채 저장하면 저장한 뒤 받은편지함에 적용한다(useMailFilterActions → useMailFilterRun).
// 이 탭에서 적용하는 중이면 창의 그 칸을 끈다(mail-filter-run.store 의 running — 화면을 떠나도 적용은 이어진다)
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
@@ -4,6 +4,7 @@ import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { CLOCK } from '@/composables/useClock'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import { LARGE_FILE_SETTINGS_TEXT } from '@/shared/constants/large-file'
import type { FileQuota, LargeFile, ShareLink } from '@/shared/types/large-file'
import { useAuthStore } from '@/stores/auth.store'
import { useLargeFilesStore } from '@/stores/large-files.store'
@@ -198,9 +199,7 @@ describe('MailLargeFilesPage', () => {
await view.get('[data-unsent-delete]').trigger('click')
await flushPromises()
expect(view.get('#large-delete-message').text()).toBe(
'‘시공도면_v4.dwg’(620 MB) 파일을 지웁니다. 이 파일을 실은 임시 저장본을 열면 첨부 칸에 지운 파일로 보입니다. 지운 파일은 되돌릴 수 없습니다.',
)
expect(view.get('#large-delete-message').text()).toBe(LARGE_FILE_SETTINGS_TEXT.deleteMessage('시공도면_v4.dwg', '620 MB'))
expect(document.activeElement?.hasAttribute('data-delete-cancel')).toBe(true)
await view.get('[data-delete-confirm]').trigger('click')
@@ -283,7 +282,7 @@ describe('MailLargeFilesPage', () => {
it('목록을 받지 못하면 사전 문구와 "다시 시도" — 알림은 띄우지 않는다', async () => {
const { wrapper: view } = await mountPage({ failLinks: true })
expect(view.text()).toContain('메일 서비스에 연결할 수 없습니다.')
expect(view.text()).toContain(ERROR_CODE_MESSAGES.MAIL_001)
expect(useToastStore().toasts).toEqual([])
expect(useLargeFilesStore().status).toBe('error')
})
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { MAIL_NOTIFY_TEXT } from '@/shared/constants/mail-notify'
import { useAuthStore } from '@/stores/auth.store'
import { useMailNotifyStore } from '@/stores/mail-notify.store'
import { useMailRealtimeStore } from '@/stores/mail-realtime.store'
@@ -48,15 +49,15 @@ describe('MailNotifyPage (설정 › 메일 › 알림)', () => {
return wrapper.get('#notify-enabled').element as HTMLInputElement
}
it('제목 · 설명 — 새 메일은 1분을 기다리지 않고 바로 목록에 나타난다', async () => {
it('제목 · 설명 — 새 메일이 오면 바로 목록에서 볼 수 있다', async () => {
const wrapper = await mountPage()
expect(wrapper.get('h1').text()).toBe('알림')
expect(wrapper.get('main').attributes('aria-labelledby')).toBe('notify-page-title')
expect(wrapper.text()).toContain('새 메일은 1분을 기다리지 않고 바로 목록에 나타납니다.')
expect(wrapper.text()).toContain(MAIL_NOTIFY_TEXT.description)
})
it('묻기 전 — 칸은 비었고 "켜면 브라우저가 … [허용]을 눌러 주세요.", 보일 것 · 시험 알림은 흐리게', async () => {
it('묻기 전 — 칸은 비었고 "켜면 브라우저가 … [허용]을 눌러 주세요.", 알림 내용 · 시험 알림은 흐리게', async () => {
const wrapper = await mountPage()
expect(enableInput(wrapper).checked).toBe(false)
@@ -132,7 +133,7 @@ describe('MailNotifyPage (설정 › 메일 › 알림)', () => {
expect(document.activeElement).toBe(enableInput(wrapper))
})
it('시험 알림을 만들 수 없는 브라우저(휴대폰 Chrome 등)면 조용히 넘어가지 않고 "지원하지 않습니다"로 바꾼다 — 초점은 안내 글로', async () => {
it('시험 알림을 만들 수 없는 브라우저(휴대폰 Chrome 등)면 조용히 넘어가지 않고 "쓸 수 없습니다"로 바꾼다 — 초점은 안내 글로', async () => {
FakeNotification.reset('granted')
const wrapper = await mountPage()
await wrapper.get('#notify-enabled').setValue(true)
@@ -143,7 +144,7 @@ describe('MailNotifyPage (설정 › 메일 › 알림)', () => {
await flushPromises()
const note = wrapper.get('#notify-enabled-note')
expect(note.text()).toBe('이 브라우저는 바탕화면 알림을 지원하지 않습니다. 새 메일은 그래도 바로 목록에 나타납니다.')
expect(note.text()).toBe(MAIL_NOTIFY_TEXT.noteUnsupported)
expect(enableInput(wrapper).disabled).toBe(true)
expect(wrapper.get('#notify-test').attributes('disabled')).toBeDefined()
expect(document.activeElement).toBe(note.element)
@@ -166,7 +167,7 @@ describe('MailNotifyPage (설정 › 메일 › 알림)', () => {
expect(enableInput(wrapper).checked).toBe(true)
})
it('끄기 — 체크를 풀면 꺼지고 보일 것 · 시험 알림이 흐려진다 (허락은 그대로)', async () => {
it('끄기 — 체크를 풀면 꺼지고 알림 내용 · 시험 알림이 흐려진다 (허락은 그대로)', async () => {
FakeNotification.reset('granted')
const wrapper = await mountPage()
await wrapper.get('#notify-enabled').setValue(true)
@@ -182,17 +183,15 @@ describe('MailNotifyPage (설정 › 메일 › 알림)', () => {
expect(wrapper.get('#notify-enabled-note').text()).toBe('이 브라우저에서 알림이 허용되어 있습니다.')
})
it('지원하지 않는 브라우저 — 칸을 끄고 "지원하지 않습니다. 새 메일은 그래도 바로 목록에"', async () => {
it('지원하지 않는 브라우저 — 칸을 끄고 "쓸 수 없습니다. 새 메일은 그래도 목록에서 바로"', async () => {
vi.stubGlobal('Notification', undefined)
const wrapper = await mountPage()
expect(enableInput(wrapper).disabled).toBe(true)
expect(wrapper.get('#notify-enabled-note').text()).toBe(
'이 브라우저는 바탕화면 알림을 지원하지 않습니다. 새 메일은 그래도 바로 목록에 나타납니다.',
)
expect(wrapper.get('#notify-enabled-note').text()).toBe(MAIL_NOTIFY_TEXT.noteUnsupported)
})
it('메일 제목도 보이기 — 끄면 저장한다 ("끄면 보낸 사람 이름만 보입니다")', async () => {
it('메일 제목도 보이기 — 끄면 저장한다 ("끄면 알림에 보낸 사람 이름만 나옵니다")', async () => {
FakeNotification.reset('granted')
const wrapper = await mountPage()
await wrapper.get('#notify-enabled').setValue(true)
@@ -200,7 +199,7 @@ describe('MailNotifyPage (설정 › 메일 › 알림)', () => {
const subject = wrapper.get('#notify-show-subject')
expect((subject.element as HTMLInputElement).checked).toBe(true)
expect(wrapper.text()).toContain('끄면 보낸 사람 이름만 보입니다. 다른 사람과 화면을 함께 보는 자리라면 꺼 두는 것이 좋습니다.')
expect(wrapper.text()).toContain(MAIL_NOTIFY_TEXT.showSubjectHint)
await subject.setValue(false)
expect(useMailNotifyStore().settings.showSubject).toBe(false)
@@ -213,7 +212,7 @@ describe('MailNotifyPage (설정 › 메일 › 알림)', () => {
const status = () => wrapper.get('#notify-realtime-status')
expect(wrapper.get('h2').text()).toBe('실시간 연결')
expect(status().text()).toBe('연결되어 있어 새 메일이 오면 바로 목록에 나타납니다.')
expect(status().text()).toBe(MAIL_NOTIFY_TEXT.realtimeLive)
expect(status().find('.realtime-link__dot').classes()).toContain('is-live')
expect(wrapper.find('#notify-realtime-notice').exists()).toBe(false)
@@ -240,7 +239,7 @@ describe('MailNotifyPage (설정 › 메일 › 알림)', () => {
expect(notes.findAll('p').map((note) => note.findAll('span').at(-1)?.text())).toEqual([
'알림 설정은 이 브라우저에만 저장됩니다. 다른 PC나 브라우저에서는 그곳에서 따로 켜 주세요.',
'웹메일 탭이 하나라도 열려 있어야 알림이 옵니다. 탭을 모두 닫으면 오지 않습니다.',
'웹메일 화면을 보고 있을 때는 새 메일이 목록에 바로 보이므로 바탕화면 알림을 보내지 않습니다.',
MAIL_NOTIFY_TEXT.noteVisible,
])
expect(notes.get('strong').text()).toBe('이 브라우저에만')
})
@@ -1,6 +1,6 @@
<script setup lang="ts">
// 설정 › 메일 › 알림 (디자인 원본 SettingsNotify · README "5단계 › 설정 › 메일 › 알림") — /settings/mail/notify
// 위에서부터 바탕화면 알림 켜기 · 알림에 보일 것 · 시험 알림(NotifySettingsForm) — 가로줄 — 실시간 연결(RealtimeLinkStatus) — 안내 상자(바탕 #F3F8F8):
// 위에서부터 바탕화면 알림 켜기 · 알림 내용 · 시험 알림(NotifySettingsForm) — 가로줄 — 실시간 연결(RealtimeLinkStatus) — 안내 상자(바탕 #F3F8F8):
// 이 브라우저에만 적용 · 웹메일 탭이 하나라도 열려 있어야 · 웹메일 화면을 보고 있을 때는 보내지 않음.
// 설정은 이 브라우저 · 사용자마다 저장한다(mail-notify.store). 들어올 때 · 탭으로 돌아올 때 브라우저의 허락을 다시 읽는다 — 사이트 설정에서 바꿨을 수 있다.
import { onBeforeUnmount, onMounted } from 'vue'
@@ -3,6 +3,7 @@ import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import { MAIL_SETTINGS_TEXT } from '@/shared/constants/mail-settings'
import type { MailSettings } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useToastStore } from '@/stores/toast.store'
@@ -86,7 +87,7 @@ describe('MailSendReadPage', () => {
'늘 보기',
])
expect(checkedImages(wrapper)).toEqual(['trusted'])
expect(wrapper.get('[data-images-note]').text()).toContain('외부 이미지는 회사를 거쳐 불러오므로 보낸 사람은 이 PC의 주소를 알 수 없습니다.')
expect(wrapper.get('[data-images-note]').text()).toContain(MAIL_SETTINGS_TEXT.imagesNote)
expect(wrapper.get('[data-settings-status]').text()).toBe('')
})
@@ -128,9 +129,7 @@ describe('MailSendReadPage', () => {
['끔', 'true'],
['켬', 'false'],
])
expect(wrapper.get('#send-read-group-hint').text()).toBe(
'답장과 전달로 이어진 메일을 메일함 목록에서 한 줄로 묶어 보여 줍니다. 메일함 목록 위의 말풍선 모양 [대화로 묶기] 단추로도 켜고 끌 수 있습니다.',
)
expect(wrapper.get('#send-read-group-hint').text()).toBe(MAIL_SETTINGS_TEXT.groupHint)
await wrapper.get('[data-group-by-conversation="true"]').trigger('click')
await flushPromises()
@@ -3,6 +3,7 @@ import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import { VACATION_TEXT } from '@/shared/constants/mail-vacation'
import type { MailVacation, MailVacationState } from '@/shared/types/mail'
import { formatDate } from '@/shared/utils/format'
import { endOfDayIso, startOfDayIso } from '@/shared/utils/mail-vacation'
@@ -111,9 +112,7 @@ describe('MailVacationPage', () => {
expect((wrapper.get('#vacation-period-range').element as HTMLInputElement).checked).toBe(true)
expect(value(wrapper, '#vacation-from')).toBe('2026-10-30')
expect(value(wrapper, '#vacation-to')).toBe('2026-11-03')
expect(wrapper.get('#vacation-period-hint').text()).toBe(
'시작일 0시부터 끝나는 날 자정까지 답합니다. 시각은 이 브라우저의 시간대를 따릅니다.',
)
expect(wrapper.get('#vacation-period-hint').text()).toBe(VACATION_TEXT.rangeHint)
expect(value(wrapper, '#vacation-subject')).toBe(SAVED.subject)
const preview = wrapper.get('[data-vacation-preview]')
expect(preview.text()).toContain('김아라 <ara.kim@arasystem.it.kr>')