generated from ttipo/project-base
feat(frontend): 실시간 새 메일 · 바탕화면 알림 · 설정 › 알림
- 로그인한 동안 앱 전체에서 실시간 연결 — 브라우저마다 하나(Web Locks 잠금 + BroadcastChannel 로 다른 탭에 나눔), fetch 로 읽기, 401 은 세션 갱신 한 번, 자리 잡은 뒤에만 다시 잇기 간격을 처음부터(열리자마자 끝나는 연결에서 갱신 폭주 막기), 탭을 닫으면 이어받기 - 메일 화면: 연결이 살아 있는 동안 1분 확인을 멈추고, 바뀌면 1초 모아 맞추기 · 숨은 탭은 탭 제목 (N) 만 - 바탕화면 알림: 웹메일을 보고 있지 않을 때만(보이고 초점이 있는 탭 없음), 한 통 · 여러 통 · 제목 숨김 문구, 누르면 그 메일로, 돌아오면 닫기 - 설정 › 메일 › 알림(허락 상태 · 제목 숨김 · 시험 알림 · 연결 상태)과 목록 위 한 번 권하기 줄, 알림 아이콘 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
// 메일 모듈 레이아웃 — 전역 셸 + 메일함 패널 + 하위 화면(목록·본문 / 검색 결과 / 쓰기)
|
||||
// 패널은 하위 화면이 바뀌어도 다시 그리지 않는다.
|
||||
// 메일 화면에 있는 동안: 상단 검색 칸은 "메일 검색", 1분마다 새 메일 확인, 탭 제목 앞에 받은편지함의 안 읽은 수 (디자인 README "3단계")
|
||||
// 메일 화면에 있는 동안: 상단 검색 칸은 "메일 검색", 새 메일 · 다른 창의 변화를 맞춘다(실시간 소식 — 끊겼으면 1분 확인),
|
||||
// 탭 제목 앞에 받은편지함의 안 읽은 수 (디자인 README "3단계" · "5단계 › 실시간 반영"). 실시간 연결 자체는 앱 전체에서 돈다(App.vue)
|
||||
// 들어올 때 부재중 자동 응답을 한 번 받는다 — 켜져 있으면 메일함 패널 아래에 안내 (계약 2절 "부재중 자동 응답")
|
||||
import AppShell from '@/components/shell/AppShell.vue'
|
||||
import MailSearchBox from '@/components/mail/MailSearchBox.vue'
|
||||
@@ -24,13 +25,15 @@ void useMailSignatureStore().ensureSignature()
|
||||
// 메일함 패널 아래의 부재중 안내 — 못 받으면 알리지 않고 그리지 않는다 (지난 값은 둔다)
|
||||
void useMailVacationStore().load()
|
||||
|
||||
// 새 메일 확인 — 바뀌었으면 메일함 개수 · 사용량과 보이는 메일함 목록을 맞춘다 (검색 결과는 저절로 바꾸지 않는다 — 스토어가 거른다)
|
||||
// 새 메일 확인 — 바뀌었으면 메일함 개수 · 사용량과 보이는 메일함 목록을 맞춘다 (검색 결과는 저절로 바꾸지 않는다 — 스토어가 거른다).
|
||||
// 숨은 탭은 받은편지함의 안 읽은 수만 고쳐 탭 제목 "(N) " 을 맞추고, 다시 보일 때 맞춘다
|
||||
useMailStatePolling({
|
||||
clock: useClock(),
|
||||
onChange: () => {
|
||||
mailStore.syncCounts()
|
||||
void mailStore.syncVisibleList()
|
||||
},
|
||||
onHiddenChange: (inboxUnread) => mailStore.setInboxUnread(inboxUnread),
|
||||
})
|
||||
useMailTabTitle()
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailNotifyStore } from '@/stores/mail-notify.store'
|
||||
import { useMailRealtimeStore } from '@/stores/mail-realtime.store'
|
||||
import { FakeBroadcastChannel } from '@/test-utils/broadcast'
|
||||
import { FakeNotification } from '@/test-utils/notification'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import { installFakeLocks, uninstallFakeLocks } from '@/test-utils/web-locks'
|
||||
import MailNotifyPage from './MailNotifyPage.vue'
|
||||
|
||||
// 설정 › 메일 › 알림 (디자인 원본 SettingsNotify · MailRealtime "막혔을 때 · 지원하지 않을 때" · README "5단계")
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.test', name: '김아라' }
|
||||
|
||||
describe('MailNotifyPage (설정 › 메일 › 알림)', () => {
|
||||
let pinia: Pinia
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
window.localStorage.clear()
|
||||
installFakeLocks()
|
||||
vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel)
|
||||
vi.stubGlobal('Notification', FakeNotification)
|
||||
FakeNotification.reset('default')
|
||||
useAuthStore().markAuthenticated(USER)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
uninstallFakeLocks()
|
||||
vi.unstubAllGlobals()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
async function mountPage() {
|
||||
const router = createTestRouter()
|
||||
await router.push('/settings/mail/notify')
|
||||
await router.isReady()
|
||||
const wrapper = mount(MailNotifyPage, { global: { plugins: [pinia, router] }, attachTo: document.body })
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
function enableInput(wrapper: Awaited<ReturnType<typeof mountPage>>) {
|
||||
return wrapper.get('#notify-enabled').element as HTMLInputElement
|
||||
}
|
||||
|
||||
it('제목 · 설명 — 새 메일은 1분을 기다리지 않고 바로 목록에 나타난다', 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분을 기다리지 않고 바로 목록에 나타납니다.')
|
||||
})
|
||||
|
||||
it('묻기 전 — 칸은 비었고 "켜면 브라우저가 … 「허용」을 눌러 주세요.", 보일 것 · 시험 알림은 흐리게', async () => {
|
||||
const wrapper = await mountPage()
|
||||
|
||||
expect(enableInput(wrapper).checked).toBe(false)
|
||||
expect(enableInput(wrapper).disabled).toBe(false)
|
||||
expect(wrapper.get('#notify-enabled-note').text()).toBe(
|
||||
'켜면 브라우저가 알림을 허락할지 한 번 묻습니다 — 「허용」을 눌러 주세요.',
|
||||
)
|
||||
expect(wrapper.get('fieldset').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.get('#notify-test').attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('켜기 → 브라우저가 묻고 허용 → 체크 · 초록 "허용되어 있습니다" · 시험 알림은 지금 탭에서 바로', async () => {
|
||||
const wrapper = await mountPage()
|
||||
FakeNotification.nextAnswer = 'granted'
|
||||
|
||||
await wrapper.get('#notify-enabled').setValue(true)
|
||||
await flushPromises()
|
||||
|
||||
expect(FakeNotification.requestCount).toBe(1)
|
||||
expect(enableInput(wrapper).checked).toBe(true)
|
||||
const note = wrapper.get('#notify-enabled-note')
|
||||
expect(note.text()).toBe('이 브라우저에서 알림이 허용되어 있습니다.')
|
||||
expect(note.classes()).toContain('notify-form__note--success')
|
||||
expect(wrapper.get('fieldset').attributes('disabled')).toBeUndefined()
|
||||
expect(useMailNotifyStore().settings.enabled).toBe(true)
|
||||
|
||||
await wrapper.get('#notify-test').trigger('click')
|
||||
expect(FakeNotification.shown.map((shown) => [shown.title, shown.options.body])).toEqual([
|
||||
['ARA 웹메일', '알림이 잘 옵니다. 새 메일이 오면 이렇게 알려 드립니다.'],
|
||||
])
|
||||
})
|
||||
|
||||
it('켜기 → 막음 → 칸을 끄고(누를 수 없다) 빨간 안내로 푸는 방법 — 초점은 그 안내 글로', async () => {
|
||||
const wrapper = await mountPage()
|
||||
FakeNotification.nextAnswer = 'denied'
|
||||
enableInput(wrapper).focus()
|
||||
|
||||
await wrapper.get('#notify-enabled').setValue(true)
|
||||
await flushPromises()
|
||||
|
||||
expect(enableInput(wrapper).checked).toBe(false)
|
||||
expect(enableInput(wrapper).disabled).toBe(true)
|
||||
const note = wrapper.get('#notify-enabled-note')
|
||||
expect(note.text()).toBe(
|
||||
'브라우저가 이 사이트의 알림을 막고 있습니다. 주소창 왼쪽의 사이트 정보(자물쇠) › 알림을 「허용」으로 바꾼 뒤 이 화면을 새로 고쳐 주세요.',
|
||||
)
|
||||
expect(note.classes()).toContain('notify-form__note--danger')
|
||||
expect(useMailNotifyStore().settings.enabled).toBe(false)
|
||||
expect(document.activeElement).toBe(note.element)
|
||||
})
|
||||
|
||||
it('허락을 묻는 동안 칸을 끄지 않고 aria-disabled 로만 막는다 — 초점이 남고, 다시 눌러도 다시 묻지 않는다', async () => {
|
||||
const wrapper = await mountPage()
|
||||
const answer = FakeNotification.holdAnswer()
|
||||
enableInput(wrapper).focus()
|
||||
|
||||
await wrapper.get('#notify-enabled').setValue(true)
|
||||
await flushPromises()
|
||||
expect(enableInput(wrapper).disabled).toBe(false)
|
||||
expect(enableInput(wrapper).getAttribute('aria-disabled')).toBe('true')
|
||||
expect(document.activeElement).toBe(enableInput(wrapper))
|
||||
|
||||
// 묻는 창이 떠 있는 동안 다시 누른다 — 되돌리고 묻지 않는다
|
||||
await wrapper.get('#notify-enabled').setValue(false)
|
||||
await flushPromises()
|
||||
expect(FakeNotification.requestCount).toBe(1)
|
||||
|
||||
answer('granted')
|
||||
await flushPromises()
|
||||
expect(enableInput(wrapper).checked).toBe(true)
|
||||
expect(enableInput(wrapper).getAttribute('aria-disabled')).toBeNull()
|
||||
expect(useMailNotifyStore().settings.enabled).toBe(true)
|
||||
expect(document.activeElement).toBe(enableInput(wrapper))
|
||||
})
|
||||
|
||||
it('시험 알림을 만들 수 없는 브라우저(휴대폰 Chrome 등)면 조용히 넘어가지 않고 "지원하지 않습니다"로 바꾼다 — 초점은 안내 글로', async () => {
|
||||
FakeNotification.reset('granted')
|
||||
const wrapper = await mountPage()
|
||||
await wrapper.get('#notify-enabled').setValue(true)
|
||||
await flushPromises()
|
||||
FakeNotification.constructorThrows = true
|
||||
|
||||
await wrapper.get('#notify-test').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const note = wrapper.get('#notify-enabled-note')
|
||||
expect(note.text()).toBe('이 브라우저는 바탕화면 알림을 지원하지 않습니다. 새 메일은 그래도 바로 목록에 나타납니다.')
|
||||
expect(enableInput(wrapper).disabled).toBe(true)
|
||||
expect(wrapper.get('#notify-test').attributes('disabled')).toBeDefined()
|
||||
expect(document.activeElement).toBe(note.element)
|
||||
})
|
||||
|
||||
it('켜기 → 그냥 닫음 → 칸은 비운 채 그대로 (다시 누르면 또 묻는다)', async () => {
|
||||
const wrapper = await mountPage()
|
||||
FakeNotification.nextAnswer = 'default'
|
||||
|
||||
await wrapper.get('#notify-enabled').setValue(true)
|
||||
await flushPromises()
|
||||
|
||||
expect(enableInput(wrapper).checked).toBe(false)
|
||||
expect(wrapper.get('#notify-enabled-note').attributes('data-note')).toBe('ask')
|
||||
|
||||
FakeNotification.nextAnswer = 'granted'
|
||||
await wrapper.get('#notify-enabled').setValue(true)
|
||||
await flushPromises()
|
||||
expect(FakeNotification.requestCount).toBe(2)
|
||||
expect(enableInput(wrapper).checked).toBe(true)
|
||||
})
|
||||
|
||||
it('끄기 — 체크를 풀면 꺼지고 보일 것 · 시험 알림이 흐려진다 (허락은 그대로)', async () => {
|
||||
FakeNotification.reset('granted')
|
||||
const wrapper = await mountPage()
|
||||
await wrapper.get('#notify-enabled').setValue(true)
|
||||
await flushPromises()
|
||||
expect(enableInput(wrapper).checked).toBe(true)
|
||||
|
||||
await wrapper.get('#notify-enabled').setValue(false)
|
||||
await flushPromises()
|
||||
|
||||
expect(useMailNotifyStore().settings.enabled).toBe(false)
|
||||
expect(wrapper.get('fieldset').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.get('#notify-test').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.get('#notify-enabled-note').text()).toBe('이 브라우저에서 알림이 허용되어 있습니다.')
|
||||
})
|
||||
|
||||
it('지원하지 않는 브라우저 — 칸을 끄고 "지원하지 않습니다. 새 메일은 그래도 바로 목록에"', async () => {
|
||||
vi.stubGlobal('Notification', undefined)
|
||||
const wrapper = await mountPage()
|
||||
|
||||
expect(enableInput(wrapper).disabled).toBe(true)
|
||||
expect(wrapper.get('#notify-enabled-note').text()).toBe(
|
||||
'이 브라우저는 바탕화면 알림을 지원하지 않습니다. 새 메일은 그래도 바로 목록에 나타납니다.',
|
||||
)
|
||||
})
|
||||
|
||||
it('메일 제목도 보이기 — 끄면 저장한다 ("끄면 보낸 사람 이름만 보입니다")', async () => {
|
||||
FakeNotification.reset('granted')
|
||||
const wrapper = await mountPage()
|
||||
await wrapper.get('#notify-enabled').setValue(true)
|
||||
await flushPromises()
|
||||
|
||||
const subject = wrapper.get('#notify-show-subject')
|
||||
expect((subject.element as HTMLInputElement).checked).toBe(true)
|
||||
expect(wrapper.text()).toContain('끄면 보낸 사람 이름만 보입니다 — 화면을 다른 사람과 함께 보는 자리라면 꺼 두세요.')
|
||||
await subject.setValue(false)
|
||||
|
||||
expect(useMailNotifyStore().settings.showSubject).toBe(false)
|
||||
})
|
||||
|
||||
it('실시간 연결 — 연결됨(초록 점) · 끊김 "연결할 수 없어 1분마다 확인합니다." · 잇는 중', async () => {
|
||||
const realtime = useMailRealtimeStore()
|
||||
realtime.setStatus('live')
|
||||
const wrapper = await mountPage()
|
||||
const status = () => wrapper.get('#notify-realtime-status')
|
||||
|
||||
expect(wrapper.get('h2').text()).toBe('실시간 연결')
|
||||
expect(status().text()).toBe('연결되어 있습니다 — 새 메일이 오면 바로 목록에 나타납니다.')
|
||||
expect(status().find('.realtime-link__dot').classes()).toContain('is-live')
|
||||
|
||||
expect(wrapper.find('#notify-realtime-notice').exists()).toBe(false)
|
||||
|
||||
realtime.setStatus('down')
|
||||
await flushPromises()
|
||||
expect(status().text()).toBe('연결할 수 없어 1분마다 확인합니다.')
|
||||
expect(status().find('.realtime-link__dot').classes()).not.toContain('is-live')
|
||||
// 끊긴 동안 — 상태 줄 바로 아래, "저절로 다시 잇습니다…" 안내 앞
|
||||
expect(wrapper.get('#notify-realtime-notice').text()).toBe('연결될 때까지 바탕화면 알림은 오지 않습니다.')
|
||||
expect(wrapper.find('[role="status"] #notify-realtime-notice').exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('저절로 다시 잇습니다.')
|
||||
|
||||
realtime.setStatus('connecting')
|
||||
await flushPromises()
|
||||
expect(status().text()).toBe('연결하는 중입니다…')
|
||||
expect(wrapper.find('#notify-realtime-notice').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('아래 안내 상자 — 이 브라우저에만 · 탭이 하나라도 열려 있어야 · 보고 있을 때는 보내지 않음', async () => {
|
||||
const wrapper = await mountPage()
|
||||
const notes = wrapper.get('[data-notify-notes]')
|
||||
|
||||
expect(notes.findAll('p').map((note) => note.findAll('span').at(-1)?.text())).toEqual([
|
||||
'알림 설정은 이 브라우저에만 적용됩니다. 다른 PC · 브라우저에서는 그곳에서 따로 켜 주세요.',
|
||||
'웹메일 탭이 하나라도 열려 있어야 알림이 옵니다. 탭을 모두 닫으면 오지 않습니다.',
|
||||
'웹메일 화면을 보고 있을 때는 바탕화면 알림을 보내지 않습니다 — 목록에 바로 보이므로.',
|
||||
])
|
||||
expect(notes.get('strong').text()).toBe('이 브라우저에만')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,92 @@
|
||||
<script setup lang="ts">
|
||||
// 설정 › 메일 › 알림 (디자인 원본 SettingsNotify · README "5단계 › 설정 › 메일 › 알림") — /settings/mail/notify
|
||||
// 위에서부터 바탕화면 알림 켜기 · 알림에 보일 것 · 시험 알림(NotifySettingsForm) — 가로줄 — 실시간 연결(RealtimeLinkStatus) — 안내 상자(바탕 #F3F8F8):
|
||||
// 이 브라우저에만 적용 · 웹메일 탭이 하나라도 열려 있어야 · 웹메일 화면을 보고 있을 때는 보내지 않음.
|
||||
// 설정은 이 브라우저 · 사용자마다 저장한다(mail-notify.store). 들어올 때 · 탭으로 돌아올 때 브라우저의 허락을 다시 읽는다 — 사이트 설정에서 바꿨을 수 있다.
|
||||
import { onBeforeUnmount, onMounted } from 'vue'
|
||||
|
||||
import NotifySettingsForm from '@/components/settings/NotifySettingsForm.vue'
|
||||
import RealtimeLinkStatus from '@/components/settings/RealtimeLinkStatus.vue'
|
||||
import SettingsPageFrame from '@/components/settings/SettingsPageFrame.vue'
|
||||
import { MAIL_NOTIFY_TEXT } from '@/shared/constants/mail-notify'
|
||||
import { useMailNotifyStore } from '@/stores/mail-notify.store'
|
||||
|
||||
const notifyStore = useMailNotifyStore()
|
||||
|
||||
function refreshPermission() {
|
||||
if (document.visibilityState === 'visible') notifyStore.refreshPermission()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
notifyStore.refreshPermission()
|
||||
window.addEventListener('focus', refreshPermission)
|
||||
document.addEventListener('visibilitychange', refreshPermission)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('focus', refreshPermission)
|
||||
document.removeEventListener('visibilitychange', refreshPermission)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SettingsPageFrame
|
||||
title-id="notify-page-title"
|
||||
:title="MAIL_NOTIFY_TEXT.title"
|
||||
:description="MAIL_NOTIFY_TEXT.description"
|
||||
>
|
||||
<NotifySettingsForm />
|
||||
<hr class="notify-page__rule">
|
||||
<RealtimeLinkStatus />
|
||||
<div
|
||||
class="notify-page__notes"
|
||||
data-notify-notes
|
||||
>
|
||||
<p class="notify-page__note">
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{{ MAIL_NOTIFY_TEXT.noteBrowserBefore }}<strong class="notify-page__strong">{{
|
||||
MAIL_NOTIFY_TEXT.noteBrowserStrong
|
||||
}}</strong>{{ MAIL_NOTIFY_TEXT.noteBrowserAfter }}</span>
|
||||
</p>
|
||||
<p class="notify-page__note">
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{{ MAIL_NOTIFY_TEXT.noteOpenTab }}</span>
|
||||
</p>
|
||||
<p class="notify-page__note">
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{{ MAIL_NOTIFY_TEXT.noteVisible }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</SettingsPageFrame>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.notify-page__rule {
|
||||
width: 100%;
|
||||
height: var(--border-width);
|
||||
margin: 0;
|
||||
border: 0;
|
||||
background: var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.notify-page__notes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
max-width: var(--note-max-width);
|
||||
padding: var(--space-14) var(--space-16);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-brand-tint);
|
||||
color: var(--color-text-strong);
|
||||
}
|
||||
|
||||
.notify-page__note {
|
||||
display: flex;
|
||||
gap: var(--space-8);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.notify-page__strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -88,10 +88,13 @@ reload()
|
||||
padding: var(--content-block-start) var(--content-inline) var(--content-block-end);
|
||||
}
|
||||
|
||||
/* 한국어 안내 글은 낱말 가운데에서 줄을 바꾸지 않는다 — 다른 설정 화면(SettingsPageFrame)과 같다. 서명 칸 · 미리 보기는 보내는 모양 그대로 둔다 */
|
||||
.signature-page__intro {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
word-break: keep-all;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.signature-page__title {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useAuthStore } from '@/stores/auth.store'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import SettingsLayout from './SettingsLayout.vue'
|
||||
|
||||
// 설정 메뉴 — 계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 (디자인 README "4단계 › 설정 메뉴")
|
||||
// 설정 메뉴 — 계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 · 알림 (디자인 README "4단계 › 설정 메뉴" · "5단계")
|
||||
|
||||
describe('SettingsLayout', () => {
|
||||
let pinia: Pinia
|
||||
@@ -34,7 +34,7 @@ describe('SettingsLayout', () => {
|
||||
return { wrapper, router }
|
||||
}
|
||||
|
||||
it('묶음 둘 — 계정(비밀번호) · 메일(서명 · 부재중 자동 응답), 묶음 이름이 목록의 이름이다', async () => {
|
||||
it('묶음 둘 — 계정(비밀번호) · 메일(서명 · 부재중 자동 응답 · 알림), 묶음 이름이 목록의 이름이다', async () => {
|
||||
const { wrapper } = await mountAt('/settings/account/password')
|
||||
const menu = wrapper.get('nav[aria-label="설정 메뉴"]')
|
||||
|
||||
@@ -46,6 +46,7 @@ describe('SettingsLayout', () => {
|
||||
[
|
||||
['서명', '/settings/mail/signature'],
|
||||
['부재중 자동 응답', '/settings/mail/vacation'],
|
||||
['알림', '/settings/mail/notify'],
|
||||
],
|
||||
])
|
||||
})
|
||||
@@ -66,5 +67,6 @@ describe('SettingsLayout', () => {
|
||||
expect(router.currentRoute.value.fullPath).toBe('/settings/account/password')
|
||||
expect(router.currentRoute.value.meta.title).toBe('비밀번호')
|
||||
expect(router.resolve({ name: 'settings-mail-vacation' }).fullPath).toBe('/settings/mail/vacation')
|
||||
expect(router.resolve({ name: 'settings-mail-notify' }).fullPath).toBe('/settings/mail/notify')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
// 설정 모듈 레이아웃 (디자인 원본 Settings · SettingsAccount · SettingsVacation) — 전역 셸 + 왼쪽 설정 메뉴(208 · #F8F9FB) + 하위 화면
|
||||
// 메뉴는 묶음 둘: 계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 (디자인 README "4단계 › 설정 메뉴", 목록은 settings-menu.ts).
|
||||
// 메뉴는 묶음 둘: 계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 · 알림 (디자인 README "4단계 › 설정 메뉴" · "5단계", 목록은 settings-menu.ts).
|
||||
// 묶음 이름은 12px #667085 굵게, 항목은 40 · 아이콘 16. 지금 화면의 항목은 aria-current="page" (RouterLink).
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import AppShell from '@/components/shell/AppShell.vue'
|
||||
|
||||
Reference in New Issue
Block a user