From def96e833b4addc324491b5d04b226a9d5e6ac7f Mon Sep 17 00:00:00 2001 From: ttipo Date: Tue, 10 Nov 2026 19:37:00 +0900 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=E2=80=BA=20=EC=A0=95=EB=B3=B4=20=E2=80=BA=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=EB=82=B4=EC=97=AD(=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=E2=86=92=20=EA=B8=80)=EC=9D=84=20=EB=8D=94=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 설정 메뉴 셋째 묶음 "정보", 목록은 게시판 표(줄 전체 링크 · [더 보기]), 글은 [← 목록으로] · 다음 글 · 이전 글 줄 · [목록] - 글을 보는 동안에도 메뉴 항목이 고른 상태다(aria-current 는 목록 page · 글 true) - 문서가 스크롤하는 좁은 폭에서도 글 · 목록이 맨 위부터 보이고(라우터 전역 scrollBehavior 없이), 위에 붙은 줄에 초점이 가리지 않는다 - 날짜는 달력 날짜 글이라 시간대를 거치지 않는다, /settings/info 는 목록으로 바꿔 끼운다 Co-Authored-By: Claude Opus 5.5 --- .../settings/ReleaseNoteArticle.vue | 169 ++++++++ .../components/settings/ReleaseNoteList.vue | 246 ++++++++++++ .../settings/ReleaseNoteNeighbors.vue | 114 ++++++ frontend/src/composables/useReleaseNotes.ts | 50 +++ .../composables/useSettingsPageScroll.spec.ts | 54 +++ .../src/composables/useSettingsPageScroll.ts | 23 ++ .../settings/InfoReleaseNotePage.spec.ts | 375 ++++++++++++++++++ .../pages/settings/InfoReleaseNotePage.vue | 274 +++++++++++++ .../settings/InfoReleaseNotesPage.spec.ts | 284 +++++++++++++ .../pages/settings/InfoReleaseNotesPage.vue | 133 +++++++ .../src/pages/settings/SettingsLayout.spec.ts | 74 +++- .../src/pages/settings/SettingsLayout.vue | 28 +- frontend/src/router/index.spec.ts | 14 + frontend/src/router/routes.ts | 26 +- .../src/shared/constants/release-notes.ts | 33 ++ .../src/shared/constants/settings-menu.ts | 13 +- frontend/src/shared/types/release-notes.ts | 27 ++ frontend/src/shared/utils/format.spec.ts | 42 ++ frontend/src/shared/utils/format.ts | 39 ++ .../src/shared/utils/release-notes.spec.ts | 149 +++++++ frontend/src/shared/utils/release-notes.ts | 115 ++++++ .../src/stores/release-notes.store.spec.ts | 285 +++++++++++++ frontend/src/stores/release-notes.store.ts | 159 ++++++++ frontend/src/test-utils/router.ts | 9 + 24 files changed, 2719 insertions(+), 16 deletions(-) create mode 100644 frontend/src/components/settings/ReleaseNoteArticle.vue create mode 100644 frontend/src/components/settings/ReleaseNoteList.vue create mode 100644 frontend/src/components/settings/ReleaseNoteNeighbors.vue create mode 100644 frontend/src/composables/useReleaseNotes.ts create mode 100644 frontend/src/composables/useSettingsPageScroll.spec.ts create mode 100644 frontend/src/composables/useSettingsPageScroll.ts create mode 100644 frontend/src/pages/settings/InfoReleaseNotePage.spec.ts create mode 100644 frontend/src/pages/settings/InfoReleaseNotePage.vue create mode 100644 frontend/src/pages/settings/InfoReleaseNotesPage.spec.ts create mode 100644 frontend/src/pages/settings/InfoReleaseNotesPage.vue create mode 100644 frontend/src/shared/constants/release-notes.ts create mode 100644 frontend/src/shared/types/release-notes.ts create mode 100644 frontend/src/shared/utils/release-notes.spec.ts create mode 100644 frontend/src/shared/utils/release-notes.ts create mode 100644 frontend/src/stores/release-notes.store.spec.ts create mode 100644 frontend/src/stores/release-notes.store.ts diff --git a/frontend/src/components/settings/ReleaseNoteArticle.vue b/frontend/src/components/settings/ReleaseNoteArticle.vue new file mode 100644 index 0000000..ef99912 --- /dev/null +++ b/frontend/src/components/settings/ReleaseNoteArticle.vue @@ -0,0 +1,169 @@ + + + + + diff --git a/frontend/src/components/settings/ReleaseNoteList.vue b/frontend/src/components/settings/ReleaseNoteList.vue new file mode 100644 index 0000000..21ee2de --- /dev/null +++ b/frontend/src/components/settings/ReleaseNoteList.vue @@ -0,0 +1,246 @@ + + + + + diff --git a/frontend/src/components/settings/ReleaseNoteNeighbors.vue b/frontend/src/components/settings/ReleaseNoteNeighbors.vue new file mode 100644 index 0000000..dc638c9 --- /dev/null +++ b/frontend/src/components/settings/ReleaseNoteNeighbors.vue @@ -0,0 +1,114 @@ + + + + + diff --git a/frontend/src/composables/useReleaseNotes.ts b/frontend/src/composables/useReleaseNotes.ts new file mode 100644 index 0000000..7016689 --- /dev/null +++ b/frontend/src/composables/useReleaseNotes.ts @@ -0,0 +1,50 @@ +import type { AxiosRequestConfig } from 'axios' + +import { getErrorMessage, notifyApiError, useApi, type ApiError } from './useApi' +import type { CursorPaginationQuery, PaginatedResult } from '@/shared/types/pagination' +import type { ReleaseNote, ReleaseNoteSummary } from '@/shared/types/release-notes' +import { releaseNoteOf, releaseNotePageOf } from '@/shared/utils/release-notes' + +/** 호출부가 정할 수 있는 요청 옵션 — 화면이 오류를 직접 그릴 때 silent, 취소가 필요하면 signal */ +export type ReleaseNoteRequestOptions = Pick + +const RELEASE_NOTES_PATH = '/release-notes' + +/** + * 응답은 왔는데 모양이 계약과 다르다 — 받지 못한 것(SYS_001)으로 본다. 인터셉터는 성공(200)으로 보고 알리지 않았으니 + * 알림을 끄지 않은 요청([더 보기])이면 다른 오류와 같은 길(notifyApiError)로 알린다(앱의 ReleaseNotesMalformedResponse 와 같은 결) + */ +function malformedReply(options?: ReleaseNoteRequestOptions): ApiError { + const failure: ApiError = { code: 'SYS_001', message: getErrorMessage('SYS_001') } + const silent = options?.silent + const quiet = silent === true || (typeof silent === 'object' && silent.includes(failure.code)) + if (!quiet) notifyApiError(failure) + return failure +} + +// 업데이트 내역 API Composable — 계약 7절 "업데이트 내역"(로그인 필요 · 제한된 세션은 막힌다). 읽기만 있다(올리는 API 는 없다) +// 인터셉터가 { success, data } 를 언래핑한다. 응답은 계약의 모양 그대로일 때만 읽는다(release-notes.ts — 하나라도 틀리면 SYS_001) +export function useReleaseNotes() { + const api = useApi() + + /** 목록 한 쪽 — 최근 글부터. 커서 · 개수는 다른 목록과 같다(기본 20 · 최대 100) */ + async function listReleaseNotes( + query: CursorPaginationQuery, + options?: ReleaseNoteRequestOptions, + ): Promise> { + const reply = await api.get(RELEASE_NOTES_PATH, { params: query, ...options }) + const page = releaseNotePageOf(reply) + if (page) return page + throw malformedReply(options) + } + + /** 글 하나 — 없는 글 · 모양이 틀린 id 는 404 RES_001. 경로에 넣는 id 는 늘 인코딩한다 */ + async function getReleaseNote(id: string, options?: ReleaseNoteRequestOptions): Promise { + const reply = await api.get(`${RELEASE_NOTES_PATH}/${encodeURIComponent(id)}`, options) + const note = releaseNoteOf(reply) + if (note) return note + throw malformedReply(options) + } + + return { listReleaseNotes, getReleaseNote } +} diff --git a/frontend/src/composables/useSettingsPageScroll.spec.ts b/frontend/src/composables/useSettingsPageScroll.spec.ts new file mode 100644 index 0000000..345f38e --- /dev/null +++ b/frontend/src/composables/useSettingsPageScroll.spec.ts @@ -0,0 +1,54 @@ +import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest' + +import { revealSettingsPageTop } from './useSettingsPageScroll' + +// 설정 화면의 맨 위가 보이게 — 업데이트 내역 2026-11-10 실사용 시험(목록을 내린 채 글을 열면 날짜 · 제목이 위로 지나가 있었다) +// jsdom 은 배치를 재지 못한다 — 문서 스크롤(documentElement.scrollTop) · 자리(getBoundingClientRect)를 꾸며 부른 것을 본다(창 높이는 768) + +/** 화면 자리만 꾸민 요소 */ +function placed(top: number, bottom: number): HTMLElement { + const element = document.createElement('div') + vi.spyOn(element, 'getBoundingClientRect').mockReturnValue({ top, bottom } as DOMRect) + document.body.append(element) + return element +} + +describe('revealSettingsPageTop', () => { + let scrollTo: MockInstance + + beforeEach(() => { + scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => undefined) + }) + + afterEach(() => { + vi.restoreAllMocks() + document.documentElement.scrollTop = 0 + document.body.innerHTML = '' + }) + + it('넓은 화면 — 따로 스크롤하는 글 칸을 맨 위로, 문서가 맨 위에 있으면 문서는 그대로', () => { + const main = placed(57, 900) + main.scrollTop = 640 + + revealSettingsPageTop(main, placed(150, 180)) + + expect(main.scrollTop).toBe(0) + expect(scrollTo).not.toHaveBeenCalled() + }) + + it('좁은 화면 · 문서를 맨 위로 올려도 제목이 화면 안이면 맨 위로 (메뉴가 옆에 있는 폭)', () => { + document.documentElement.scrollTop = 174 + // 문서 맨 위에서 칸은 57, 제목 아래 끝은 178 — 창 높이(768) 안 + revealSettingsPageTop(placed(57 - 174, 1200), placed(178 - 174, 178 - 174)) + + expect(scrollTo).toHaveBeenCalledWith({ top: 0, behavior: 'instant' }) + }) + + it('좁은 화면 · 위에 메뉴가 길어 맨 위로는 제목이 보이지 않으면 글 칸의 위 끝을 화면 위 끝에 (휴대폰)', () => { + document.documentElement.scrollTop = 1007 + // 문서 맨 위에서 칸은 906 — 제목 아래 끝(1027)이 창 높이(768)를 넘는다 + revealSettingsPageTop(placed(906 - 1007, 2400), placed(1027 - 1007, 1027 - 1007)) + + expect(scrollTo).toHaveBeenCalledWith({ top: 906, behavior: 'instant' }) + }) +}) diff --git a/frontend/src/composables/useSettingsPageScroll.ts b/frontend/src/composables/useSettingsPageScroll.ts new file mode 100644 index 0000000..b486c87 --- /dev/null +++ b/frontend/src/composables/useSettingsPageScroll.ts @@ -0,0 +1,23 @@ +// 설정 화면의 스크롤 — 앱 안에서 옮겨 온 화면의 맨 위(머리 · 제목)가 보이게 한다 (업데이트 내역 — 2026-11-10 실사용 시험) +// 같은 문서 안의 이동이라 앞 화면의 스크롤 자리가 남는다 — 목록을 내린 채 글을 열면 글의 날짜 · 제목이 위로 지나가 빈 글처럼 보였고, +// 글에서 목록으로 돌아오면 목록의 맨 아래 근처가 보였다. 라우터 전역 scrollBehavior 는 쓰지 않는다(메일 등 다른 화면의 뒤로 가기까지 바뀐다) — +// 쓰는 화면이 내용을 그린 뒤(받은 글 · 목록을 그린 뒤) 부른다. +// 스크롤하는 것이 폭마다 다르다 — 둘 다 맞춘다: +// - 넓은 화면(70rem 이상 — SettingsPageFrame 과 같은 기준)은 글 칸(main)이 따로 스크롤한다 → 그 칸을 맨 위로 +// - 좁은 화면은 문서가 스크롤한다 → 문서를 맨 위로 올려도 머리가 화면 안에 들면 맨 위로, 아니면(위에 메뉴가 길다 — 휴대폰 · 낮은 창) +// 글 칸의 위 끝을 화면 위 끝에 맞춘다. 문서가 맨 위에 있으면 그대로 둔다(처음 연 화면 — 다른 설정 화면과 같다) + +/** + * @param main 화면의 글 칸(main) — 넓은 화면에서 따로 스크롤하는 칸 + * @param head 꼭 보여야 할 머리(제목 h1 · 받지 못함 안내) — 문서를 맨 위로 올려도 이것이 화면 안이면 맨 위로 올린다 + */ +export function revealSettingsPageTop(main: HTMLElement, head: HTMLElement): void { + main.scrollTop = 0 + const scroller = document.scrollingElement ?? document.documentElement + const scrolled = scroller.scrollTop + if (scrolled <= 0) return + // 문서의 맨 위에서 잰 자리 — 지금 화면 자리에 스크롤한 만큼을 더한다 + const headBottom = head.getBoundingClientRect().bottom + scrolled + const mainTop = main.getBoundingClientRect().top + scrolled + window.scrollTo({ top: headBottom <= window.innerHeight ? 0 : Math.max(0, mainTop), behavior: 'instant' }) +} diff --git a/frontend/src/pages/settings/InfoReleaseNotePage.spec.ts b/frontend/src/pages/settings/InfoReleaseNotePage.spec.ts new file mode 100644 index 0000000..7e3fb45 --- /dev/null +++ b/frontend/src/pages/settings/InfoReleaseNotePage.spec.ts @@ -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 = { [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((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 + 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 = 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 }]]) + }) + }) +}) diff --git a/frontend/src/pages/settings/InfoReleaseNotePage.vue b/frontend/src/pages/settings/InfoReleaseNotePage.vue new file mode 100644 index 0000000..a338254 --- /dev/null +++ b/frontend/src/pages/settings/InfoReleaseNotePage.vue @@ -0,0 +1,274 @@ + + + + + diff --git a/frontend/src/pages/settings/InfoReleaseNotesPage.spec.ts b/frontend/src/pages/settings/InfoReleaseNotesPage.spec.ts new file mode 100644 index 0000000..215e49c --- /dev/null +++ b/frontend/src/pages/settings/InfoReleaseNotesPage.spec.ts @@ -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((settle) => { + resolve = settle + }) + return { promise, resolve } +} + +describe('InfoReleaseNotesPage', () => { + let pinia: Pinia + let requests: MockRequest[] + let router: Router + let scrollTo: MockInstance + 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) { + 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) { + 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() + }) +}) diff --git a/frontend/src/pages/settings/InfoReleaseNotesPage.vue b/frontend/src/pages/settings/InfoReleaseNotesPage.vue new file mode 100644 index 0000000..ba1aa8e --- /dev/null +++ b/frontend/src/pages/settings/InfoReleaseNotesPage.vue @@ -0,0 +1,133 @@ + + + + + diff --git a/frontend/src/pages/settings/SettingsLayout.spec.ts b/frontend/src/pages/settings/SettingsLayout.spec.ts index fa5f355..b230076 100644 --- a/frontend/src/pages/settings/SettingsLayout.spec.ts +++ b/frontend/src/pages/settings/SettingsLayout.spec.ts @@ -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"]') diff --git a/frontend/src/pages/settings/SettingsLayout.vue b/frontend/src/pages/settings/SettingsLayout.vue index eefdbec..f302ffc 100644 --- a/frontend/src/pages/settings/SettingsLayout.vue +++ b/frontend/src/pages/settings/SettingsLayout.vue @@ -1,7 +1,10 @@