generated from ttipo/project-base
feat(frontend): 설정 › 정보 › 업데이트 내역(목록 → 글)을 더한다
- 설정 메뉴 셋째 묶음 "정보", 목록은 게시판 표(줄 전체 링크 · [더 보기]), 글은 [← 목록으로] · 다음 글 · 이전 글 줄 · [목록] - 글을 보는 동안에도 메뉴 항목이 고른 상태다(aria-current 는 목록 page · 글 true) - 문서가 스크롤하는 좁은 폭에서도 글 · 목록이 맨 위부터 보이고(라우터 전역 scrollBehavior 없이), 위에 붙은 줄에 초점이 가리지 않는다 - 날짜는 달력 날짜 글이라 시간대를 거치지 않는다, /settings/info 는 목록으로 바꿔 끼운다 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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"]')
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user