feat(frontend): 설정 › 정보 › 업데이트 내역(목록 → 글)을 더한다

- 설정 메뉴 셋째 묶음 "정보", 목록은 게시판 표(줄 전체 링크 · [더 보기]), 글은 [← 목록으로] · 다음 글 · 이전 글 줄 · [목록]
- 글을 보는 동안에도 메뉴 항목이 고른 상태다(aria-current 는 목록 page · 글 true)
- 문서가 스크롤하는 좁은 폭에서도 글 · 목록이 맨 위부터 보이고(라우터 전역 scrollBehavior 없이), 위에 붙은 줄에 초점이 가리지 않는다
- 날짜는 달력 날짜 글이라 시간대를 거치지 않는다, /settings/info 는 목록으로 바꿔 끼운다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-10 19:37:00 +09:00
parent 1d01130c9d
commit def96e833b
24 changed files with 2719 additions and 16 deletions
@@ -0,0 +1,375 @@
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { createRouter, createWebHistory, type Router } from 'vue-router'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import type { ReleaseNote, ReleaseNoteSummary } from '@/shared/types/release-notes'
import { useAuthStore } from '@/stores/auth.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'
import InfoReleaseNotePage from './InfoReleaseNotePage.vue'
// 설정 › 정보 › 업데이트 내역 — 글 (디자인 원본 ReleaseNotes A2 · README "업데이트 내역 › 글")
// 맨 위 글 · 가운데 글(다른 글 줄) · 없는 글(404 RES_001 — 알림 없이 그 화면) · 받지 못함 — 어떤 상태에서도 위에 [← 목록으로] 줄
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
const LIST_PATH = '/settings/info/release-notes'
const TOP_SUMMARY: ReleaseNoteSummary = {
id: '2026-11-18',
date: '2026-11-18',
title: '메일 검색이 빨라지고 휴대폰 앱이 나왔습니다',
}
const MID_SUMMARY: ReleaseNoteSummary = { id: '2026-11-05', date: '2026-11-05', title: '화면의 안내 글을 다듬었습니다' }
const MENU_SUMMARY: ReleaseNoteSummary = { id: '2026-11-04', date: '2026-11-04', title: '왼쪽 메뉴를 정리했습니다' }
/** 맨 위 글 — 다음 글 없음 · 이전 글은 가운데 글 */
const TOP: ReleaseNote = {
...TOP_SUMMARY,
sections: [
{
heading: '휴대폰 앱',
items: [
'안드로이드 휴대폰 앱이 나왔습니다. 웹메일의 [설정] → [휴대폰 앱]에서 받을 수 있습니다.',
'앱에서 메일을 읽고 쓰고 찾을 수 있고, 새 메일이 오면 휴대폰으로 알려 드립니다. 알림에는 새 메일이 몇 통인지만 나오고, 보낸 사람이나 제목은 나오지 않습니다.',
],
},
{ heading: '메일 검색', items: ['찾은 말에는 노란 표시가 붙습니다.'] },
],
previous: MID_SUMMARY,
next: null,
}
/** 가운데 글 — 머리 없는 묶음 · 다음 글(맨 위 글) · 이전 글 */
const MID: ReleaseNote = {
...MID_SUMMARY,
sections: [
{
heading: null,
items: [
'단추와 안내 글을 더 알기 쉬운 말로 고쳤습니다.',
'브라우저 탭과 휴대폰 홈 화면의 아이콘을 ARA SYSTEM 마크로 바꿨습니다.',
],
},
],
previous: MENU_SUMMARY,
next: TOP_SUMMARY,
}
const NOTES: Record<string, ReleaseNote> = { [TOP.id]: TOP, [MID.id]: MID }
function noteReply(request: MockRequest): MockResult {
const id = decodeURIComponent(request.url.replace('/release-notes/', ''))
const note = NOTES[id]
return note ? ok(note) : fail(404, 'RES_001')
}
/** 응답을 손으로 보내는 요청 — 받는 동안의 화면을 본다 */
function deferredReply() {
let resolve: (result: MockResult) => void = () => {}
const promise = new Promise<MockResult>((settle) => {
resolve = settle
})
return { promise, resolve }
}
/**
* 좁은 화면(문서가 스크롤)을 꾸민다 — 문서를 scrolled 만큼 내린 채, 문서 맨 위에서 잰 글 칸(main)의 위 끝과 머리(head)의 아래 끝.
* jsdom 은 배치를 재지 못한다 — getBoundingClientRect 를 꾸민다(창 높이는 768)
*/
function narrowLayout(scrolled: number, mainTop: number, headBottom: number, head: string) {
document.documentElement.scrollTop = scrolled
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) {
if (this.matches('main')) return { top: mainTop - scrolled, bottom: 0 } as DOMRect
if (this.matches(head)) return { top: 0, bottom: headBottom - scrolled } as DOMRect
return { top: 0, bottom: 0 } as DOMRect
})
}
describe('InfoReleaseNotePage', () => {
let pinia: Pinia
let requests: MockRequest[]
let scrollTo: MockInstance<typeof window.scrollTo>
const mounted: VueWrapper[] = []
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated(USER)
scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => undefined)
})
afterEach(() => {
for (const wrapper of mounted.splice(0)) {
if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
}
vi.restoreAllMocks()
useToastStore().clear()
restoreApiAdapter()
document.documentElement.scrollTop = 0
document.body.innerHTML = ''
})
async function mountAt(
id: string,
reply: (request: MockRequest) => MockResult | Promise<MockResult> = noteReply,
router: Router = createTestRouter(),
) {
requests = installMockApi(reply)
await router.push(LIST_PATH)
await router.push(`${LIST_PATH}/${id}`)
await router.isReady()
const wrapper = mount(InfoReleaseNotePage, { attachTo: document.body, global: { plugins: [pinia, router] } })
mounted.push(wrapper)
await flushPromises()
return { wrapper, router }
}
const requestLines = () => requests.map((request) => `${request.method} ${request.url}`)
const neighborRows = (wrapper: VueWrapper) =>
wrapper.findAll('nav[aria-label="다른 글"] a').map((row) => ({
kind: row.attributes('data-release-note-neighbor'),
text: row.findAll('span, time').map((cell) => cell.text()),
href: row.attributes('href'),
}))
describe('글 (A2)', () => {
it('맨 위 글 — 날짜 · 제목(h1) · 묶음(머리 + 점 목록), 위에 [← 목록으로] 줄 · 끝에 [목록]', async () => {
const { wrapper } = await mountAt(TOP.id)
const main = wrapper.get('main')
const title = wrapper.get('h1')
expect(title.text()).toBe('메일 검색이 빨라지고 휴대폰 앱이 나왔습니다')
expect(main.attributes('aria-labelledby')).toBe(title.attributes('id'))
expect(main.attributes('aria-label')).toBeUndefined()
const date = wrapper.get('article time')
expect(date.text()).toBe('2026년 11월 18일')
expect(date.attributes('datetime')).toBe('2026-11-18')
const sections = wrapper.findAll('[data-release-note-section]')
expect(sections.map((section) => section.find('h2').text())).toEqual(['휴대폰 앱', '메일 검색'])
expect(sections[0]?.findAll('li').map((item) => item.text())).toEqual(TOP.sections[0]?.items)
const back = wrapper.get('a[data-release-note-back]')
expect(back.text()).toBe('목록으로')
expect(back.attributes('href')).toBe(LIST_PATH)
expect(back.classes()).toContain('tool-button--accent')
const list = wrapper.get('a[data-release-note-list]')
expect(list.text()).toBe('목록')
expect(list.attributes('href')).toBe(LIST_PATH)
expect(requestLines()).toEqual(['GET /release-notes/2026-11-18'])
expect(requests[0]?.config.silent).toBe(true)
})
it('맨 위 글이라 다음 글 줄은 없고 이전 글 줄만 — 줄 전체가 그 글로 가는 링크', async () => {
const { wrapper } = await mountAt(TOP.id)
expect(neighborRows(wrapper)).toEqual([
{
kind: 'older',
text: ['이전 글', '화면의 안내 글을 다듬었습니다', '2026년 11월 5일'],
href: `${LIST_PATH}/2026-11-05`,
},
])
})
it('가운데 글 — 다음 글(더 새 글)이 위 · 이전 글(더 옛 글)이 아래, 머리 없는 묶음은 점 목록만', async () => {
const { wrapper } = await mountAt(MID.id)
expect(neighborRows(wrapper)).toEqual([
{
kind: 'newer',
text: ['다음 글', '메일 검색이 빨라지고 휴대폰 앱이 나왔습니다', '2026년 11월 18일'],
href: `${LIST_PATH}/2026-11-18`,
},
{
kind: 'older',
text: ['이전 글', '왼쪽 메뉴를 정리했습니다', '2026년 11월 4일'],
href: `${LIST_PATH}/2026-11-04`,
},
])
const section = wrapper.get('[data-release-note-section]')
expect(section.find('h2').exists()).toBe(false)
expect(section.findAll('li')).toHaveLength(2)
})
it('글이 하나뿐이면 다른 글 칸째 없다 · [목록]은 그대로', async () => {
const { wrapper } = await mountAt(TOP.id, () => ok({ ...TOP, previous: null, next: null }))
expect(wrapper.find('nav[aria-label="다른 글"]').exists()).toBe(false)
expect(wrapper.find('a[data-release-note-list]').exists()).toBe(true)
})
it('다른 글 줄을 누르면 기록을 바꿔 끼우고 그 글을 받는다 — 누른 줄이 사라져 잃은 초점은 새 제목으로', async () => {
const { wrapper, router } = await mountAt(MID.id)
const newer = wrapper.get('a[data-release-note-neighbor="newer"]')
;(newer.element as HTMLElement).focus()
await newer.trigger('click', { button: 0 })
await flushPromises()
expect(router.currentRoute.value.params.id).toBe(TOP.id)
expect(requestLines()).toEqual(['GET /release-notes/2026-11-05', 'GET /release-notes/2026-11-18'])
const title = wrapper.get('h1')
expect(title.text()).toBe(TOP.title)
expect(document.activeElement).toBe(title.element)
// 바꿔 끼웠으니 뒤로 가기는 목록으로 간다
router.back()
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe(LIST_PATH)
})
})
describe('목록으로', () => {
it('[← 목록으로] · [목록] — 바로 앞 기록이 목록이 아니면 목록으로 바꿔 끼운다', async () => {
const { wrapper, router } = await mountAt(TOP.id)
await wrapper.get('a[data-release-note-list]').trigger('click', { button: 0 })
await flushPromises()
expect(router.currentRoute.value.name).toBe('settings-info-release-notes')
})
it('바로 앞 기록이 목록이면 뒤로 가기를 쓴다 (기록을 쌓지 않는다)', async () => {
// 메모리 history 는 history.state 를 쓰지 않으므로 실제 브라우저 history 로 확인한다
const router = createRouter({ history: createWebHistory(), routes: createTestRouter().options.routes })
const { wrapper } = await mountAt(TOP.id, noteReply, router)
await wrapper.get('a[data-release-note-back]').trigger('click', { button: 0 })
await vi.waitFor(() => expect(router.currentRoute.value.fullPath).toBe(LIST_PATH))
// 뒤로 간 것이라 앞으로 갈 기록(보던 글)이 남아 있다 — 바꿔 끼웠다면 없다
expect(router.options.history.state.forward).toBe(`${LIST_PATH}/${TOP.id}`)
})
})
describe('상태', () => {
it('받는 동안은 글 자리를 막대로 — 위의 [← 목록으로] 줄은 그대로', async () => {
const reply = deferredReply()
const { wrapper } = await mountAt(TOP.id, () => reply.promise)
const placeholder = wrapper.get('[data-release-note-loading]')
expect(placeholder.attributes('aria-busy')).toBe('true')
expect(placeholder.attributes('aria-label')).toBe('글 불러오는 중')
expect(wrapper.find('a[data-release-note-back]').exists()).toBe(true)
expect(wrapper.get('main').attributes('aria-label')).toBe('업데이트 내역')
reply.resolve(ok(TOP))
await flushPromises()
expect(wrapper.find('[data-release-note-loading]').exists()).toBe(false)
expect(wrapper.get('h1').text()).toBe(TOP.title)
})
it('없는 글(404 RES_001) — 알림 없이 사전 문구와 [목록으로], 위 줄은 그대로', async () => {
const { wrapper, router } = await mountAt('2026-01-01')
const error = wrapper.get('[data-release-note-error]')
expect(error.attributes('role')).toBe('alert')
expect(error.text()).toContain(ERROR_CODE_MESSAGES.RES_001)
expect(error.text()).toContain('문의 코드 3f1c0a2e')
expect(wrapper.find('a[data-release-note-back]').exists()).toBe(true)
expect(wrapper.find('article').exists()).toBe(false)
expect(useToastStore().toasts).toEqual([])
const leave = error.get('button')
expect(leave.text()).toBe('목록으로')
await leave.trigger('click')
await flushPromises()
expect(router.currentRoute.value.name).toBe('settings-info-release-notes')
})
it('글 id 꼴이 아닌 주소는 묻지 않고 없는 글 화면', async () => {
const { wrapper } = await mountAt('latest')
expect(requests).toEqual([])
const error = wrapper.get('[data-release-note-error]')
expect(error.text()).toContain(ERROR_CODE_MESSAGES.RES_001)
expect(error.get('button').text()).toBe('목록으로')
})
it('받지 못함 — 알림 없이 사전 문구 · [다시 시도] · 문의 코드, [다시 시도]는 같은 글을 다시 받는다', async () => {
let attempt = 0
const { wrapper } = await mountAt(TOP.id, () => {
attempt += 1
return attempt === 1 ? fail(502, 'SYS_001') : ok(TOP)
})
const error = wrapper.get('[data-release-note-error]')
expect(error.text()).toContain(ERROR_CODE_MESSAGES.SYS_001)
expect(error.text()).toContain('문의 코드 3f1c0a2e')
expect(useToastStore().toasts).toEqual([])
await error.get('button').trigger('click')
await flushPromises()
expect(requestLines()).toEqual(['GET /release-notes/2026-11-18', 'GET /release-notes/2026-11-18'])
expect(wrapper.get('h1').text()).toBe(TOP.title)
})
})
// 2026-11-10 실사용 시험 — 목록을 내린 채 글을 열면 앞 화면의 스크롤 자리를 이어받아 날짜 · 제목이 위로 지나가 있었다
describe('글의 맨 위가 보이게', () => {
it('받은 뒤에 맞춘다 — 문서를 맨 위로 올려도 제목이 보이면 맨 위로 (메뉴가 옆에 있는 폭 1000×600)', async () => {
narrowLayout(174, 57, 178, 'h1')
const reply = deferredReply()
await mountAt(TOP.id, () => reply.promise)
expect(scrollTo).not.toHaveBeenCalled()
reply.resolve(ok(TOP))
await flushPromises()
expect(scrollTo.mock.calls).toEqual([[{ top: 0, behavior: 'instant' }]])
})
it('위에 메뉴가 길어 맨 위로는 제목이 보이지 않으면 글 칸의 위 끝을 화면 위 끝에 (휴대폰 360×800)', async () => {
narrowLayout(1007, 906, 1027, 'h1')
await mountAt(TOP.id)
expect(scrollTo.mock.calls).toEqual([[{ top: 906, behavior: 'instant' }]])
})
it('없는 글이어도 그 안내가 보이게 맞춘다', async () => {
narrowLayout(1007, 906, 1240, '[data-release-note-error]')
const { wrapper } = await mountAt('2026-01-01')
expect(wrapper.find('[data-release-note-error]').exists()).toBe(true)
expect(scrollTo.mock.calls).toEqual([[{ top: 906, behavior: 'instant' }]])
})
it('문서가 맨 위에 있으면(처음 연 화면) 문서는 그대로 둔다', async () => {
await mountAt(TOP.id)
expect(scrollTo).not.toHaveBeenCalled()
})
it('다른 글로 옮기면 따로 스크롤하는 글 칸(넓은 화면)도 맨 위로 — 새 제목에는 스크롤 없이 초점만', async () => {
const { wrapper } = await mountAt(MID.id)
const main = wrapper.get('main').element as HTMLElement
main.scrollTop = 700
const focus = vi.spyOn(HTMLElement.prototype, 'focus')
const newer = wrapper.get('a[data-release-note-neighbor="newer"]')
;(newer.element as HTMLElement).focus()
await newer.trigger('click', { button: 0 })
await flushPromises()
const title = wrapper.get('h1')
expect(title.text()).toBe(TOP.title)
expect(main.scrollTop).toBe(0)
expect(document.activeElement).toBe(title.element)
const titleFocus = focus.mock.calls.filter((_, index) => focus.mock.contexts[index] === title.element)
expect(titleFocus).toEqual([[{ preventScroll: true }]])
})
})
})
@@ -0,0 +1,274 @@
<script setup lang="ts">
// 설정 › 정보 › 업데이트 내역 — 글 (디자인 원본 ReleaseNotes A2 · A3 · README "업데이트 내역") — /settings/info/release-notes/{id} · 탭 제목 "업데이트 내역"
// id 는 글 파일 이름(예 2026-11-18). 글을 보는 동안에도 설정 메뉴의 업데이트 내역이 고른 상태다(라우트를 목록 아래에 묶었다 — routes.ts)
// - 위: [← 목록으로] 줄(52 — 목록 보기에서 연 메일의 줄과 같은 꼴 · 스크롤해도 위에 남는다). 어떤 상태에서도 둔다
// - 글: 날짜 · 제목(h1) · 묶음(ReleaseNoteArticle) → 다른 글 줄(ReleaseNoteNeighbors — 없는 쪽은 줄이 없고, 둘 다 없으면 칸째 없다) → [목록](44)
// - 받는 동안은 글 자리를 막대로. 없는 글(404 RES_001 · 글 id 꼴이 아닌 주소): 사전 문구 + [목록으로], 그 밖의 실패: 사전 문구 + [다시 시도] — 알림 없음
// - [← 목록으로] · [목록] · 없는 글의 [목록으로]는 바로 앞 기록이 목록이면 뒤로 가기, 아니면 목록으로 바꿔 끼운다(기록을 쌓지 않는다 — 메일의 목록으로와 같다).
// 새 탭 · 새 창으로 여는 누름(Ctrl · ⌘ · Shift · 가운데 단추)은 브라우저에 맡긴다
// - 글을 열거나 다른 글로 옮기면(같은 화면이 주소만 바뀐다) 받은 뒤 글의 맨 위(날짜 · 제목)가 보이게 스크롤을 맞춘다 — 앞 화면의 스크롤 자리를 이어받지 않게
// (revealSettingsPageTop — 넓은 화면은 글 칸, 좁은 화면은 문서). 다른 글로 옮겨 누른 줄이 사라져 초점을 잃었으면 새 제목으로 옮긴다
// - 위에 붙은 [← 목록으로] 줄 밑에 초점이 가리지 않게 글 칸의 제목 · 링크 · 단추는 줄 높이만큼 띄워 스크롤한다(scroll-margin-top)
import { computed, nextTick, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import MailLoadError from '@/components/mail/MailLoadError.vue'
import MailToolButton from '@/components/mail/MailToolButton.vue'
import ReleaseNoteArticle from '@/components/settings/ReleaseNoteArticle.vue'
import ReleaseNoteNeighbors from '@/components/settings/ReleaseNoteNeighbors.vue'
import { useMailNavigation } from '@/composables/useMailNavigation'
import { revealSettingsPageTop } from '@/composables/useSettingsPageScroll'
import { RELEASE_NOTES_TEXT } from '@/shared/constants/release-notes'
import { SETTINGS_ROUTES } from '@/shared/constants/settings-menu'
import { releaseNoteNeighbors } from '@/shared/utils/release-notes'
import { useReleaseNotesStore } from '@/stores/release-notes.store'
const TITLE_ID = 'release-note-title'
const LIST_LOCATION = { name: SETTINGS_ROUTES.releaseNotes }
const route = useRoute()
const router = useRouter()
const releaseNotesStore = useReleaseNotesStore()
const { backOrReplace } = useMailNavigation()
const mainRef = ref<HTMLElement | null>(null)
const articleRef = ref<InstanceType<typeof ReleaseNoteArticle> | null>(null)
/** 주소의 글 id — 이 화면의 라우트일 때만(떠나는 사이 바뀐 주소로 다시 받지 않게) */
const noteId = computed<string | null>(() => {
const id = route.params.id
return route.name === SETTINGS_ROUTES.releaseNote && typeof id === 'string' ? id : null
})
const listHref = computed(() => router.resolve(LIST_LOCATION).href)
const note = computed(() => (releaseNotesStore.noteStatus === 'ready' ? releaseNotesStore.note : null))
const neighbors = computed(() => (note.value ? releaseNoteNeighbors(note.value) : []))
const failure = computed(() =>
releaseNotesStore.noteStatus === 'missing' || releaseNotesStore.noteStatus === 'error'
? releaseNotesStore.noteError
: null,
)
/** 누른 줄 · 단추가 사라져 초점을 잃었는지 */
function focusLost(): boolean {
const active = document.activeElement
return active === null || active === document.body || !active.isConnected
}
function backToList() {
void backOrReplace(LIST_LOCATION)
}
/** 목록으로 가는 링크 — 새 탭 · 새 창으로 여는 누름은 브라우저에 맡긴다 */
function onListLink(event: MouseEvent) {
if (event.ctrlKey || event.metaKey || event.shiftKey || event.altKey || event.button !== 0) return
event.preventDefault()
backToList()
}
function retry() {
if (noteId.value !== null) void releaseNotesStore.loadNote(noteId.value)
}
/**
* 글을 받고 그린 뒤 글의 맨 위가 보이게 맞춘다(없는 글 · 받지 못함이면 그 안내가 보이게).
* 다른 글에서 옮겨 왔고(moved) 누른 줄이 사라져 초점을 잃었으면 새 제목으로 — 스크롤은 위에서 맞췄으니 초점만 옮긴다
*/
async function open(id: string, moved: boolean) {
await releaseNotesStore.loadNote(id)
// 그사이 다른 글로 옮겼으면 그 글이 맞춘다 · 화면을 떠났으면 할 일이 없다
if (noteId.value !== id) return
await nextTick()
const main = mainRef.value
if (!main) return
revealSettingsPageTop(main, main.querySelector<HTMLElement>('h1, [data-release-note-error]') ?? main)
if (moved && releaseNotesStore.noteStatus === 'ready' && focusLost()) articleRef.value?.focusTitle()
}
// 처음 그리기 전에 받기 시작한다 — 지난번에 받아 둔 글을 잠깐 그렸다 바꾸지 않게. 같은 화면에서 주소의 id 만 바뀌어도 다시 받는다
watch(
noteId,
(id, previous) => {
if (id !== null) void open(id, typeof previous === 'string')
},
{ immediate: true },
)
</script>
<template>
<main
ref="mainRef"
class="release-note"
:aria-labelledby="note ? TITLE_ID : undefined"
:aria-label="note ? undefined : RELEASE_NOTES_TEXT.title"
>
<div class="release-note__bar">
<div class="release-note__bar-row">
<MailToolButton
icon="arrow-left"
:label="RELEASE_NOTES_TEXT.backToList"
tone="accent"
:href="listHref"
:icon-stroke-width="1.9"
data-release-note-back
@click="onListLink"
/>
</div>
</div>
<div class="release-note__column">
<MailLoadError
v-if="failure"
:error="failure"
:leave-label="RELEASE_NOTES_TEXT.backToList"
data-release-note-error
@retry="retry"
@leave="backToList"
/>
<template v-else-if="note">
<ReleaseNoteArticle
ref="articleRef"
:note="note"
:title-id="TITLE_ID"
/>
<ReleaseNoteNeighbors
v-if="neighbors.length > 0"
:neighbors="neighbors"
/>
<div class="release-note__actions">
<a
:href="listHref"
class="release-note__list-link"
data-release-note-list
@click="onListLink"
>{{ RELEASE_NOTES_TEXT.list }}</a>
</div>
</template>
<div
v-else
class="release-note__placeholder"
aria-busy="true"
:aria-label="RELEASE_NOTES_TEXT.noteLoading"
data-release-note-loading
>
<span class="release-note__placeholder-bar release-note__placeholder-bar--date" />
<span class="release-note__placeholder-bar release-note__placeholder-bar--title" />
<span class="release-note__placeholder-bar release-note__placeholder-bar--heading" />
<span class="release-note__placeholder-bar" />
<span class="release-note__placeholder-bar release-note__placeholder-bar--short" />
</div>
</div>
</main>
</template>
<style scoped>
/* 설정 화면의 틀(SettingsPageFrame)과 같은 칸 — 위에 [← 목록으로] 줄이 하나 더 있다 */
.release-note {
flex: 999 1 35rem;
min-width: 0;
display: flex;
flex-direction: column;
word-break: keep-all;
overflow-wrap: anywhere;
}
/* [← 목록으로] 줄 — 칸 전체 폭으로 아래 선을 긋고, 스크롤해도 위에 남는다 (읽기 화면의 도구 줄과 같다) */
.release-note__bar {
position: sticky;
top: 0;
z-index: 1;
border-bottom: var(--border-width) solid var(--color-border);
background: var(--color-surface);
}
/* 콘텐츠 열 안 — 안쪽 22 + 단추 안쪽 10 = 아이콘이 32 선 */
.release-note__bar-row {
display: flex;
align-items: center;
max-width: var(--content-max-width);
min-height: var(--size-header-min);
padding: var(--space-4) var(--content-inline) var(--space-4) var(--content-toolbar-start);
}
.release-note__column {
max-width: var(--content-max-width);
display: flex;
flex-direction: column;
gap: var(--space-22);
padding: var(--content-block-start) var(--content-inline) var(--content-block-end);
}
/*
* 위에 붙은 [← 목록으로] 줄(52 + 선 1) 밑에 초점이 가리지 않게 (WCAG 2.4.11) — 초점을 옮기며 스크롤할 때 줄 높이 + 8 만큼 띄운다.
* 스크롤하는 것이 글 칸이든 문서든 같다
*/
.release-note__column :deep(:is(h1, a, button, [tabindex])) {
scroll-margin-top: calc(var(--size-header-min) + var(--border-width) + var(--space-8));
}
.release-note__actions {
display: flex;
}
/* [목록] 44 — 흰 바탕 · 테두리 */
.release-note__list-link {
display: inline-flex;
align-items: center;
min-height: var(--size-touch);
padding: 0 var(--space-18);
border: var(--border-width) solid var(--color-border-strong);
border-radius: var(--radius-md);
background: var(--color-surface);
color: var(--color-text-strong);
font-weight: 600;
text-decoration: none;
}
.release-note__list-link:hover {
background: var(--color-surface-muted);
color: var(--color-text-strong);
}
/* 받는 동안 — 날짜 · 제목 · 묶음 자리 (목록의 막대와 같은 결) */
.release-note__placeholder {
display: flex;
flex-direction: column;
gap: var(--space-12);
}
.release-note__placeholder-bar {
width: 80%;
max-width: var(--content-measure);
height: var(--space-10);
border-radius: var(--radius-xs);
background: var(--color-border-subtle);
}
.release-note__placeholder-bar--date {
width: 7.5rem;
background: var(--color-skeleton);
}
.release-note__placeholder-bar--title {
width: 55%;
height: var(--space-18);
margin-bottom: var(--space-12);
background: var(--color-skeleton);
}
.release-note__placeholder-bar--heading {
width: 20%;
height: var(--space-12);
background: var(--color-skeleton);
}
.release-note__placeholder-bar--short {
width: 45%;
}
/* 넓은 화면에서는 이 칸만 따로 스크롤한다 (설정 화면의 틀과 같다) */
@media (min-width: 70rem) {
.release-note {
overflow-y: auto;
}
}
</style>
@@ -0,0 +1,284 @@
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import type { Router } from 'vue-router'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import type { ReleaseNoteSummary } from '@/shared/types/release-notes'
import { useAuthStore } from '@/stores/auth.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'
import InfoReleaseNotesPage from './InfoReleaseNotesPage.vue'
// 설정 › 정보 › 업데이트 내역 — 목록 (디자인 원본 ReleaseNotes A1 · README "업데이트 내역 › 목록")
// 글이 있을 때(머리 줄 · 줄 전체 링크 · [더 보기]) · 불러오는 중(막대) · 받지 못함(사전 문구 · [다시 시도] · 문의 코드) · 글이 없을 때
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
/** 보드의 넷 — 맨 위 글은 다음 배포의 첫 글 초안 */
const BOARD_NOTES: ReleaseNoteSummary[] = [
{ id: '2026-11-18', date: '2026-11-18', title: '메일 검색이 빨라지고 휴대폰 앱이 나왔습니다' },
{ id: '2026-11-05', date: '2026-11-05', title: '화면의 안내 글을 다듬었습니다' },
{ id: '2026-11-04', date: '2026-11-04', title: '왼쪽 메뉴를 정리했습니다' },
{ id: '2026-11-03', date: '2026-11-03', title: '2단계 인증과 앱 비밀번호를 쓸 수 있습니다' },
]
/** 20개를 넘는 목록 — 첫 쪽 20 · 둘째 쪽 2 */
const MANY = Array.from({ length: 22 }, (_, index): ReleaseNoteSummary => {
const id = `2026-10-${String(31 - index).padStart(2, '0')}`
return { id, date: id, title: `${31 - index}일의 업데이트` }
})
const lastPage = (items: ReleaseNoteSummary[]) => ({ items, pageInfo: { hasNext: false, nextCursor: null, total: items.length } })
/** 응답을 손으로 보내는 요청 — 받는 동안의 화면을 본다 */
function deferredReply() {
let resolve: (result: MockResult) => void = () => {}
const promise = new Promise<MockResult>((settle) => {
resolve = settle
})
return { promise, resolve }
}
describe('InfoReleaseNotesPage', () => {
let pinia: Pinia
let requests: MockRequest[]
let router: Router
let scrollTo: MockInstance<typeof window.scrollTo>
const mounted: VueWrapper[] = []
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated(USER)
scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => undefined)
})
afterEach(() => {
for (const wrapper of mounted.splice(0)) {
if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
}
vi.restoreAllMocks()
useToastStore().clear()
restoreApiAdapter()
document.documentElement.scrollTop = 0
document.body.innerHTML = ''
})
async function mountPage(reply: (request: MockRequest) => MockResult | Promise<MockResult>) {
requests = installMockApi(reply)
router = createTestRouter()
await router.push('/settings/info/release-notes')
await router.isReady()
const wrapper = mount(InfoReleaseNotesPage, { attachTo: document.body, global: { plugins: [pinia, router] } })
mounted.push(wrapper)
await flushPromises()
return wrapper
}
const requestLines = () => requests.map((request) => `${request.method} ${request.url}`)
describe('글이 있을 때 (A1)', () => {
it('머리 · 설명 한 줄 — 목록은 머리 줄(제목 · 날짜) 아래 최근 글부터 한 줄씩, 날짜는 "2026년 11월 18일"', async () => {
const wrapper = await mountPage(() => ok(lastPage(BOARD_NOTES)))
expect(wrapper.get('h1').text()).toBe('업데이트 내역')
expect(wrapper.text()).toContain('웹메일과 휴대폰 앱에 새로 생기거나 바뀐 것을 알려 드립니다.')
const head = wrapper.get('.release-note-list__head')
expect(head.attributes('aria-hidden')).toBe('true')
expect(head.findAll('span').map((cell) => cell.text())).toEqual(['제목', '날짜'])
const rows = wrapper.findAll('a[data-release-note-row]')
expect(rows.map((row) => row.get('[data-release-note-title]').text())).toEqual(BOARD_NOTES.map((note) => note.title))
expect(rows.map((row) => row.get('[data-release-note-date]').text())).toEqual([
'2026년 11월 18일',
'2026년 11월 5일',
'2026년 11월 4일',
'2026년 11월 3일',
])
expect(rows[0]?.get('time').attributes('datetime')).toBe('2026-11-18')
expect(requestLines()).toEqual(['GET /release-notes'])
expect(requests[0]?.params).toEqual({ limit: 20 })
})
it('목록 이름은 화면 제목이고, 줄 전체가 그 글로 가는 링크다', async () => {
const wrapper = await mountPage(() => ok(lastPage(BOARD_NOTES)))
const list = wrapper.get('ul[role="list"]')
expect(list.attributes('aria-labelledby')).toBe(wrapper.get('h1').attributes('id'))
const first = list.get('a[data-release-note-row]')
expect(first.attributes('href')).toBe('/settings/info/release-notes/2026-11-18')
await first.trigger('click', { button: 0 })
await flushPromises()
expect(router.currentRoute.value.name).toBe('settings-info-release-note')
expect(router.currentRoute.value.params.id).toBe('2026-11-18')
})
it('20개뿐이면 [더 보기]가 없다', async () => {
const wrapper = await mountPage(() => ok(lastPage(MANY.slice(0, 20))))
expect(wrapper.findAll('a[data-release-note-row]')).toHaveLength(20)
expect(wrapper.find('[data-release-notes-more]').exists()).toBe(false)
})
})
describe('[더 보기]', () => {
function pagedReply(second: () => MockResult | Promise<MockResult>) {
return (request: MockRequest) =>
request.params.cursor === 'c20'
? second()
: ok({ items: MANY.slice(0, 20), pageInfo: { hasNext: true, nextCursor: 'c20', total: 22 } })
}
it('20개씩 이어 받는다 — 받는 동안 그 단추만 바쁨, 마지막 쪽이면 단추가 사라지고 초점은 새로 붙은 첫 줄로', async () => {
const second = deferredReply()
const wrapper = await mountPage(pagedReply(() => second.promise))
const more = wrapper.get('button[data-release-notes-more]')
expect(more.text()).toBe('더 보기')
;(more.element as HTMLButtonElement).focus()
await more.trigger('click')
expect(more.attributes('aria-busy')).toBe('true')
expect(more.attributes('aria-disabled')).toBe('true')
expect(wrapper.findAll('a[data-release-note-row]')).toHaveLength(20)
second.resolve(ok(lastPage(MANY.slice(20))))
await flushPromises()
const rows = wrapper.findAll('a[data-release-note-row]')
expect(rows).toHaveLength(22)
expect(wrapper.find('[data-release-notes-more]').exists()).toBe(false)
expect(document.activeElement).toBe(rows[20]?.element)
expect(requests.map((request) => request.params)).toEqual([{ limit: 20 }, { cursor: 'c20', limit: 20 }])
})
it('더 받지 못하면 알림으로 알리고 받은 줄 · 단추 · 초점은 그대로', async () => {
const wrapper = await mountPage(pagedReply(() => fail(503, 'SYS_001')))
const more = wrapper.get('button[data-release-notes-more]')
;(more.element as HTMLButtonElement).focus()
await more.trigger('click')
await flushPromises()
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([ERROR_CODE_MESSAGES.SYS_001])
expect(wrapper.findAll('a[data-release-note-row]')).toHaveLength(20)
expect(more.attributes('aria-busy')).toBeUndefined()
expect(document.activeElement).toBe(more.element)
})
})
it('불러오는 중 — 머리 줄은 두고 줄 자리를 막대 넷으로, [더 보기]는 없다', async () => {
const reply = deferredReply()
const wrapper = await mountPage(() => reply.promise)
const box = wrapper.get('[data-release-notes-loading]')
expect(box.attributes('aria-busy')).toBe('true')
expect(box.attributes('aria-label')).toBe('업데이트 내역 불러오는 중')
expect(box.find('.release-note-list__head').exists()).toBe(true)
expect(box.findAll('.release-note-list__skeleton-row')).toHaveLength(4)
expect(wrapper.find('a[data-release-note-row]').exists()).toBe(false)
expect(wrapper.find('[data-release-notes-more]').exists()).toBe(false)
reply.resolve(ok(lastPage(BOARD_NOTES)))
await flushPromises()
expect(wrapper.find('[data-release-notes-loading]').exists()).toBe(false)
expect(wrapper.findAll('a[data-release-note-row]')).toHaveLength(4)
})
it('받지 못함 — 표 자리에 사전 문구 · [다시 시도] · 문의 코드, 알림은 띄우지 않는다. [다시 시도]는 다시 받는다', async () => {
let attempt = 0
const wrapper = await mountPage(() => {
attempt += 1
return attempt === 1 ? fail(503, 'SYS_001') : ok(lastPage(BOARD_NOTES))
})
const error = wrapper.get('[data-release-notes-error]')
expect(error.attributes('role')).toBe('alert')
expect(error.text()).toContain(ERROR_CODE_MESSAGES.SYS_001)
expect(error.text()).toContain('문의 코드 3f1c0a2e')
expect(wrapper.find('.release-note-list').exists()).toBe(false)
expect(useToastStore().toasts).toEqual([])
await error.get('button').trigger('click')
await flushPromises()
expect(requestLines()).toEqual(['GET /release-notes', 'GET /release-notes'])
expect(wrapper.findAll('a[data-release-note-row]')).toHaveLength(4)
})
it('목록이 404 여도 빠져나갈 곳이 없어 [다시 시도]다', async () => {
const wrapper = await mountPage(() => fail(404, 'RES_001'))
const error = wrapper.get('[data-release-notes-error]')
expect(error.text()).toContain(ERROR_CODE_MESSAGES.RES_001)
expect(error.get('button').text()).toBe('다시 시도')
await error.get('button').trigger('click')
await flushPromises()
expect(requestLines()).toEqual(['GET /release-notes', 'GET /release-notes'])
})
it('글이 없을 때 — 표와 [더 보기] 없이 한 줄만', async () => {
const wrapper = await mountPage(() => ok(lastPage([])))
const empty = wrapper.get('[data-release-notes-empty]')
expect(empty.text()).toBe('아직 알려 드릴 업데이트가 없습니다.')
expect(empty.attributes('role')).toBeUndefined()
expect(wrapper.find('.release-note-list').exists()).toBe(false)
expect(wrapper.find('[data-release-notes-more]').exists()).toBe(false)
})
// 2026-11-10 실사용 시험 — 좁은 폭에서 글에서 돌아오면 앞 화면의 스크롤 자리를 이어받아 목록의 맨 아래 근처가 보였다
it('받은 뒤 목록의 맨 위가 보이게 맞춘다 — 위에 메뉴가 길면 목록 칸의 위 끝을 화면 위 끝에, [더 보기] 뒤에는 맞추지 않는다', async () => {
// 휴대폰 360×800 — 문서를 1007 내린 채, 문서 맨 위에서 목록 칸은 906 · 제목 아래 끝은 931 (창 높이 768 을 넘는다)
document.documentElement.scrollTop = 1007
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) {
if (this.matches('main')) return { top: 906 - 1007, bottom: 0 } as DOMRect
if (this.matches('h1')) return { top: 0, bottom: 931 - 1007 } as DOMRect
return { top: 0, bottom: 0 } as DOMRect
})
const second = deferredReply()
const wrapper = await mountPage((request) =>
request.params.cursor === 'c20'
? second.promise
: ok({ items: MANY.slice(0, 20), pageInfo: { hasNext: true, nextCursor: 'c20', total: 22 } }),
)
expect(scrollTo.mock.calls).toEqual([[{ top: 906, behavior: 'instant' }]])
await wrapper.get('button[data-release-notes-more]').trigger('click')
second.resolve(ok(lastPage(MANY.slice(20))))
await flushPromises()
expect(wrapper.findAll('a[data-release-note-row]')).toHaveLength(22)
expect(scrollTo).toHaveBeenCalledTimes(1)
})
it('문서가 맨 위에 있으면(처음 연 화면) 문서는 그대로 두고, 따로 스크롤하는 칸(넓은 화면)은 [다시 시도]로 받은 뒤에도 맨 위로', async () => {
let attempt = 0
const wrapper = await mountPage(() => {
attempt += 1
return attempt === 1 ? fail(503, 'SYS_001') : ok(lastPage(BOARD_NOTES))
})
const main = wrapper.get('main').element as HTMLElement
main.scrollTop = 300
await wrapper.get('[data-release-notes-error] button').trigger('click')
await flushPromises()
expect(wrapper.findAll('a[data-release-note-row]')).toHaveLength(4)
expect(main.scrollTop).toBe(0)
expect(scrollTo).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,133 @@
<script setup lang="ts">
// 설정 › 정보 › 업데이트 내역 — 목록 (디자인 원본 ReleaseNotes A1 · A3 · README "업데이트 내역") — /settings/info/release-notes · 탭 제목 "업데이트 내역"
// 머리 "업데이트 내역" + 설명 한 줄 → 목록 칸(ReleaseNoteList — 최근 글부터, 줄을 누르면 그 글) → 더 있으면 [더 보기](44)
// - 들어올 때마다 새로 받는다(release-notes.store). 받는 동안은 머리 줄 + 막대 넷. 받은 뒤 목록의 맨 위가 보이게 스크롤을 맞춘다 —
// 글에서 돌아왔을 때 앞 화면의 스크롤 자리를 이어받지 않게(revealSettingsPageTop — 넓은 화면은 글 칸, 좁은 화면은 문서). [더 보기] 뒤에는 맞추지 않는다
// - 받지 못함: 칸 자리에 사전 문구 · [다시 시도] · 문의 코드(MailLoadError — 알림 없음). 목록은 빠져나갈 곳이 없어 없는 주소(404)여도 [다시 시도]
// - 글이 없음: "아직 알려 드릴 업데이트가 없습니다." 한 줄(표 · [더 보기] 없이)
// - [더 보기]: 20개씩 이어 받는다 — 받는 동안 그 단추만 바쁨(aria-busy · aria-disabled), 받지 못하면(응답의 모양이 틀려도) 알리고 받은 줄은 그대로.
// 마지막 쪽을 받아 단추가 사라지며 초점을 잃었으면 새로 붙은 첫 줄로 옮긴다
import { nextTick, ref } from 'vue'
import StateMessage from '@/components/feedback/StateMessage.vue'
import MailLoadError from '@/components/mail/MailLoadError.vue'
import ReleaseNoteList from '@/components/settings/ReleaseNoteList.vue'
import SettingsPageFrame from '@/components/settings/SettingsPageFrame.vue'
import { revealSettingsPageTop } from '@/composables/useSettingsPageScroll'
import { RELEASE_NOTES_TEXT } from '@/shared/constants/release-notes'
import { useReleaseNotesStore } from '@/stores/release-notes.store'
const TITLE_ID = 'release-notes-page-title'
const releaseNotesStore = useReleaseNotesStore()
const frameRef = ref<InstanceType<typeof SettingsPageFrame> | null>(null)
const listRef = ref<InstanceType<typeof ReleaseNoteList> | null>(null)
/** 누른 단추가 사라져 초점을 잃었는지 */
function focusLost(): boolean {
const active = document.activeElement
return active === null || active === document.body || !active.isConnected
}
/** 첫 쪽부터 받고, 그린 뒤 목록의 맨 위(제목)가 보이게 맞춘다 — 화면을 떠났으면 그대로 */
async function reload() {
await releaseNotesStore.loadList()
await nextTick()
const main: unknown = frameRef.value?.$el
if (!(main instanceof HTMLElement)) return
revealSettingsPageTop(main, main.querySelector<HTMLElement>('h1') ?? main)
}
async function loadMore() {
const firstAdded = await releaseNotesStore.loadMore()
if (firstAdded === null) return
await nextTick()
if (focusLost()) listRef.value?.focusRow(firstAdded)
}
// 처음 그리기 전에 — 지난번에 받아 둔 목록을 잠깐 그렸다 바꾸지 않게
void reload()
</script>
<template>
<SettingsPageFrame
ref="frameRef"
:title-id="TITLE_ID"
:title="RELEASE_NOTES_TEXT.title"
:description="RELEASE_NOTES_TEXT.description"
>
<MailLoadError
v-if="releaseNotesStore.listStatus === 'error' && releaseNotesStore.listError"
:error="releaseNotesStore.listError"
:leave-label="RELEASE_NOTES_TEXT.retry"
data-release-notes-error
@retry="reload"
@leave="reload"
/>
<StateMessage
v-else-if="releaseNotesStore.listStatus === 'ready' && releaseNotesStore.notes.length === 0"
icon="file-text"
:title="RELEASE_NOTES_TEXT.empty"
data-release-notes-empty
/>
<div
v-else
class="release-notes"
>
<ReleaseNoteList
ref="listRef"
:notes="releaseNotesStore.notes"
:labelledby="TITLE_ID"
:loading="releaseNotesStore.listStatus !== 'ready'"
/>
<div
v-if="releaseNotesStore.hasMore"
class="release-notes__more"
>
<button
type="button"
class="release-notes__more-button"
:aria-disabled="releaseNotesStore.loadingMore ? 'true' : undefined"
:aria-busy="releaseNotesStore.loadingMore ? 'true' : undefined"
data-release-notes-more
@click="loadMore"
>
{{ RELEASE_NOTES_TEXT.more }}
</button>
</div>
</div>
</SettingsPageFrame>
</template>
<style scoped>
.release-notes {
display: flex;
flex-direction: column;
gap: var(--space-16);
}
.release-notes__more {
display: flex;
}
/* [더 보기] 44 — 흰 바탕 · 테두리 (로그인 기록의 [더 보기]와 같다) */
.release-notes__more-button {
min-height: var(--size-touch);
padding: 0 var(--space-16);
border: var(--border-width) solid var(--color-border-strong);
border-radius: var(--radius-md);
background: var(--color-surface);
color: var(--color-text-strong);
font-weight: 600;
}
.release-notes__more-button:hover:not([aria-disabled='true']) {
background: var(--color-surface-muted);
}
.release-notes__more-button[aria-disabled='true'] {
opacity: 0.6;
cursor: progress;
}
</style>
@@ -6,12 +6,14 @@ import { createMemoryHistory, createRouter } from 'vue-router'
import { routes } from '@/router/routes'
import { useAuthStore } from '@/stores/auth.store'
import { flushPromises } from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
import SettingsLayout from './SettingsLayout.vue'
// 설정 메뉴 — 계정 › 비밀번호 · 2단계 인증 · 앱 비밀번호 · 메일 프로그램 연결 · 휴대폰 앱 · 로그인 기록 / 메일 › 서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 · 차단한 주소 · 대용량 첨부 · 알림
// / 정보 › 업데이트 내역
// (디자인 README "4단계 › 설정 메뉴" · "5단계" · "6단계" · "9단계 › 설정 · 보내기 · 읽기 · 차단한 주소" · "11단계 › 설정 › 메일 › 대용량 첨부" · "12단계 — 계정 보안" ·
// "13단계" · "휴대폰 앱 받기")
// "13단계" · "휴대폰 앱 받기" · "업데이트 내역")
describe('SettingsLayout', () => {
let pinia: Pinia
@@ -36,13 +38,17 @@ describe('SettingsLayout', () => {
return { wrapper, router }
}
it('묶음 둘 — 계정(비밀번호 · 2단계 인증 · 앱 비밀번호 · 메일 프로그램 연결 · 휴대폰 앱 · 로그인 기록) · 메일(서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 · 차단한 주소 · 대용량 첨부 · 알림), 묶음 이름이 목록의 이름이다', async () => {
it('묶음 셋 — 계정(비밀번호 · 2단계 인증 · 앱 비밀번호 · 메일 프로그램 연결 · 휴대폰 앱 · 로그인 기록) · 메일(서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 · 차단한 주소 · 대용량 첨부 · 알림) · 정보(업데이트 내역), 묶음 이름이 목록의 이름이다', async () => {
const { wrapper } = await mountAt('/settings/account/password')
const menu = wrapper.get('nav[aria-label="설정 메뉴"]')
expect(menu.findAll('.settings-menu__group').map((group) => group.text())).toEqual(['계정', '메일'])
expect(menu.findAll('.settings-menu__group').map((group) => group.text())).toEqual(['계정', '메일', '정보'])
const lists = menu.findAll('ul')
expect(lists.map((list) => list.attributes('aria-labelledby'))).toEqual(['settings-menu-account', 'settings-menu-mail'])
expect(lists.map((list) => list.attributes('aria-labelledby'))).toEqual([
'settings-menu-account',
'settings-menu-mail',
'settings-menu-info',
])
expect(lists.map((list) => list.findAll('a').map((link) => [link.text(), link.attributes('href')]))).toEqual([
[
['비밀번호', '/settings/account/password'],
@@ -61,9 +67,69 @@ describe('SettingsLayout', () => {
['대용량 첨부', '/settings/mail/large-files'],
['알림', '/settings/mail/notify'],
],
[['업데이트 내역', '/settings/info/release-notes']],
])
})
it('설정 › 정보 › 업데이트 내역 — 목록 /settings/info/release-notes · 글 /settings/info/release-notes/<글 id>, 로그인이 필요하고 탭 제목은 둘 다 "업데이트 내역" (앱의 라우트 표 그대로)', async () => {
const router = createRouter({ history: createMemoryHistory(), routes })
await router.push('/settings/info/release-notes')
expect(router.currentRoute.value.name).toBe('settings-info-release-notes')
expect(router.currentRoute.value.meta).toMatchObject({ title: '업데이트 내역', requiresAuth: true })
await router.push('/settings/info/release-notes/2026-11-18-2')
expect(router.currentRoute.value.name).toBe('settings-info-release-note')
expect(router.currentRoute.value.params.id).toBe('2026-11-18-2')
expect(router.currentRoute.value.meta).toMatchObject({ title: '업데이트 내역', requiresAuth: true })
expect(router.resolve({ name: 'settings-info-release-notes' }).fullPath).toBe('/settings/info/release-notes')
expect(router.resolve({ name: 'settings-info-release-note', params: { id: '2026-11-18' } }).fullPath).toBe(
'/settings/info/release-notes/2026-11-18',
)
})
it('/settings/info 는 업데이트 내역 목록으로 바꿔 끼운다 — 글 주소의 ".." 를 브라우저가 다듬은 주소, 다른 묶음 주소는 그대로', async () => {
const router = createRouter({ history: createMemoryHistory(), routes })
for (const path of ['/settings/info', '/settings/info/']) {
await router.push(path)
expect(router.currentRoute.value.name, path).toBe('settings-info-release-notes')
expect(router.currentRoute.value.fullPath, path).toBe('/settings/info/release-notes')
}
expect(router.resolve('/settings/account').name).toBe('not-found')
expect(router.resolve('/settings/mail').name).toBe('not-found')
})
it('업데이트 내역 메뉴는 문서 아이콘(file-text) — 목록이면 aria-current="page", 글을 보는 동안에는 고른 모양 그대로 aria-current="true"', async () => {
for (const [path, current] of [
['/settings/info/release-notes', 'page'],
['/settings/info/release-notes/2026-11-18', 'true'],
] as const) {
const { wrapper } = await mountAt(path)
const marked = wrapper.get('nav[aria-label="설정 메뉴"]').findAll('a[aria-current]')
expect(marked.map((link) => [link.text(), link.attributes('aria-current')]), path).toEqual([['업데이트 내역', current]])
expect(marked[0]?.classes()).toContain('is-active')
expect(marked[0]?.find('path').attributes('d')).toBe('M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z')
expect(marked[0]?.find('polyline').attributes('points')).toBe('14 2 14 8 20 8')
wrapper.unmount()
}
})
it('메뉴 항목을 누르면 그 화면으로 간다 — 글을 보다가 업데이트 내역을 누르면 목록', async () => {
const { wrapper, router } = await mountAt('/settings/info/release-notes/2026-11-18')
const item = wrapper
.get('nav[aria-label="설정 메뉴"]')
.findAll('a')
.find((link) => link.text() === '업데이트 내역')
await item?.trigger('click', { button: 0 })
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/settings/info/release-notes')
})
it('지금 화면의 항목만 현재로 표시한다', async () => {
const { wrapper } = await mountAt('/settings/mail/vacation')
const current = wrapper.get('nav[aria-label="설정 메뉴"]').findAll('a[aria-current="page"]')
+19 -9
View File
@@ -1,7 +1,10 @@
<script setup lang="ts">
// 설정 모듈 레이아웃 (디자인 원본 Settings · SettingsAccount · SettingsVacation) — 전역 셸 + 왼쪽 설정 메뉴(208 · #F8F9FB) + 하위 화면
// 메뉴는 묶음 둘: 계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 · 필터 · 알림 (디자인 README "4단계 › 설정 메뉴" · "5단계" · "6단계", 목록은 settings-menu.ts).
// 묶음 이름은 12px #667085 굵게, 항목은 40 · 아이콘 16. 지금 화면의 항목은 aria-current="page" (RouterLink).
// 메뉴는 묶음 셋: 계정 › 비밀번호 … / 메일 › 서명 · 부재중 자동 응답 · 필터 · 알림 … / 정보 › 업데이트 내역
// (디자인 README "4단계 › 설정 메뉴" · "5단계" · "6단계" · "업데이트 내역", 목록은 settings-menu.ts).
// 묶음 이름은 12px #667085 굵게, 항목은 40 · 아이콘 16. 지금 화면의 항목은 청록 바탕 + aria-current="page" 다 —
// 그 항목 아래에 묶은 화면(업데이트 내역의 글)을 보는 동안에도 고른 상태(청록 바탕)로 두되, 링크가 가리키는 곳(목록)과 지금 쪽(글)이 달라
// aria-current 는 "true" 다(RouterLink 의 isExactActive · isActive — 메일함 패널의 지금 메일함과 같은 결)
import AppIcon from '@/components/AppIcon.vue'
import AppShell from '@/components/shell/AppShell.vue'
import { SETTINGS_MENU } from '@/shared/constants/settings-menu'
@@ -36,15 +39,22 @@ function groupLabelId(groupId: string): string {
:key="item.routeName"
>
<RouterLink
v-slot="{ href, navigate, isActive, isExactActive }"
:to="{ name: item.routeName }"
class="settings-menu__link"
active-class="is-active"
custom
>
<AppIcon
:name="item.icon"
:size="16"
/>
<span>{{ item.label }}</span>
<a
:href="href"
:class="['settings-menu__link', { 'is-active': isActive }]"
:aria-current="isExactActive ? 'page' : isActive ? 'true' : undefined"
@click="navigate"
>
<AppIcon
:name="item.icon"
:size="16"
/>
<span>{{ item.label }}</span>
</a>
</RouterLink>
</li>
</ul>