feat: 업데이트 내역 게시판 · 쓰기 화면 [보내기] 숨음과 목록 고르기 칸 고침 #26

Merged
ttipo merged 12 commits from feat/release-notes into main 2026-11-11 06:24:46 +09:00
24 changed files with 2719 additions and 16 deletions
Showing only changes of commit def96e833b - Show all commits
@@ -0,0 +1,169 @@
<script setup lang="ts">
// 설정 › 정보 › 업데이트 내역 › 글 본문 (디자인 원본 ReleaseNotes A2 · A3 · README "업데이트 내역 › 글")
// 날짜(12.5 회색) · 제목(h1 18) · 묶음(머리 h2 14 + 점 목록 13 · 줄 간격 1.6) — 묶음 글은 680 까지.
// 글은 서버가 다듬은 글 그대로 그린다 — 마크다운 해석 · v-html 없음, 대괄호 글([설정] → [휴대폰 앱])도 꾸미지 않는다. 머리가 없는 묶음은 점 목록만
// 날짜는 달력 날짜 그대로 "2026년 11월 18일"(시간대를 바꾸지 않는다 — formatCalendarDate)
// 사용 예: <ReleaseNoteArticle ref="articleRef" :note="note" title-id="release-note-title" />
import { ref } from 'vue'
import type { ReleaseNote } from '@/shared/types/release-notes'
import { formatCalendarDate } from '@/shared/utils/format'
interface Props {
note: ReleaseNote
/** 제목(h1)의 id — 화면(main)이 aria-labelledby 로 가리킨다 */
titleId: string
}
defineProps<Props>()
const titleRef = ref<HTMLElement | null>(null)
defineExpose({
/**
* 제목으로 초점 — 다른 글 줄을 눌러 글이 바뀌었을 때 잃은 초점을 둔다.
* 화면이 먼저 글의 맨 위가 보이게 스크롤을 맞춘 뒤 부르므로 여기서는 스크롤하지 않는다
*/
focusTitle() {
titleRef.value?.focus({ preventScroll: true })
},
})
</script>
<template>
<article
class="release-note-article"
:aria-labelledby="titleId"
>
<div class="release-note-article__head">
<p class="release-note-article__date">
<time
:datetime="note.date"
data-release-note-date
>{{ formatCalendarDate(note.date) }}</time>
</p>
<h1
:id="titleId"
ref="titleRef"
class="release-note-article__title"
tabindex="-1"
>
{{ note.title }}
</h1>
</div>
<div class="release-note-article__sections">
<div
v-for="(section, index) in note.sections"
:key="index"
class="release-note-article__section"
data-release-note-section
>
<h2
v-if="section.heading"
class="release-note-article__heading"
>
{{ section.heading }}
</h2>
<ul
class="release-note-article__items"
role="list"
>
<li
v-for="(item, itemIndex) in section.items"
:key="itemIndex"
class="release-note-article__item"
>
{{ item }}
</li>
</ul>
</div>
</div>
</article>
</template>
<style scoped>
.release-note-article {
display: flex;
flex-direction: column;
gap: var(--space-22);
}
.release-note-article__head {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.release-note-article__date {
margin: 0;
font-size: var(--font-size-label);
font-weight: 500;
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
.release-note-article__title {
margin: 0;
font-size: var(--font-size-title-lg);
line-height: var(--line-height-heading);
font-weight: 700;
letter-spacing: var(--letter-spacing-tight);
}
/* 초점은 다른 글로 옮겨 왔을 때만 받는다 — 키보드로 왔을 때만 고리를 그린다 */
.release-note-article__title:focus {
outline: none;
}
.release-note-article__title:focus-visible {
outline: var(--focus-ring-width) solid var(--color-brand);
outline-offset: var(--focus-ring-offset);
}
.release-note-article__sections {
max-width: var(--content-measure);
display: flex;
flex-direction: column;
gap: var(--space-18);
}
.release-note-article__section {
display: flex;
flex-direction: column;
gap: var(--space-6);
}
.release-note-article__heading {
margin: 0;
font-size: var(--font-size-body);
line-height: var(--line-height-heading);
font-weight: 700;
color: var(--color-text);
}
.release-note-article__items {
display: flex;
flex-direction: column;
gap: var(--space-4);
margin: 0;
padding: 0;
list-style: none;
color: var(--color-text-strong);
line-height: 1.6;
}
/* 점 4 — 앞 칸 14 안, 첫 글줄의 가운데(13 × 1.6 = 20.8 의 가운데 — 위 8.5) */
.release-note-article__item {
display: grid;
grid-template-columns: 0.875rem minmax(0, 1fr);
}
.release-note-article__item::before {
content: '';
width: var(--space-4);
height: var(--space-4);
margin-top: 0.53125rem;
border-radius: var(--radius-round);
background: var(--color-icon-muted);
}
</style>
@@ -0,0 +1,246 @@
<script setup lang="ts">
// 설정 › 정보 › 업데이트 내역 › 목록 칸 (디자인 원본 ReleaseNotes A1 · A3 · README "업데이트 내역 › 목록")
// - 설정의 로그인 기록 표와 같은 상자(테두리 · 모서리 10) — 머리 줄(제목 · 날짜 136, 높이 32 #FBFBFC) + 글마다 한 줄(48 이상), 최근 글부터
// - 줄 전체가 그 글로 가는 링크다 — 마우스를 올리면 옅은 청록 바탕, 키보드 초점은 줄 안쪽 2px 청록. 제목이 길면 줄을 바꾸고 날짜는 오른쪽 칸에 둔다
// - 머리 줄은 보이는 꼴만이다(aria-hidden) — 줄 링크가 제목과 날짜를 함께 읽는다. 목록 이름은 화면 제목(labelledby)
// - 불러오는 중(loading): 머리 줄은 두고 줄 자리를 막대 넷으로 잡아 둔다
// - 목록 칸이 480 보다 좁으면(컨테이너 쿼리 — 화면 폭이 아니라 이 칸의 폭) 머리 줄을 감추고 날짜를 제목 아래로 내린다
// 사용 예: <ReleaseNoteList :notes="store.notes" labelledby="release-notes-page-title" />
import { ref } from 'vue'
import { RELEASE_NOTES_TEXT } from '@/shared/constants/release-notes'
import { SETTINGS_ROUTES } from '@/shared/constants/settings-menu'
import type { ReleaseNoteSummary } from '@/shared/types/release-notes'
import { formatCalendarDate } from '@/shared/utils/format'
interface Props {
notes: readonly ReleaseNoteSummary[]
/** 목록 이름이 될 화면 제목의 id */
labelledby: string
/** 처음 받는 중 — 줄 자리를 막대로 */
loading?: boolean
}
withDefaults(defineProps<Props>(), {
loading: false,
})
/** 불러오는 중 막대 줄 수 (길이는 줄마다 CSS 로 달리한다 — 보드 54 · 38 · 62 · 46%) */
const SKELETON_ROWS = 4
const rootRef = ref<HTMLElement | null>(null)
defineExpose({
/** 그 자리의 줄로 초점 — [더 보기]로 받은 마지막 쪽에서 단추가 사라졌을 때 새로 붙은 첫 줄로 옮긴다 */
focusRow(index: number) {
rootRef.value?.querySelectorAll<HTMLElement>('[data-release-note-row]')[index]?.focus()
},
})
</script>
<template>
<div
ref="rootRef"
class="release-note-list"
>
<div
class="release-note-list__box"
:aria-busy="loading ? 'true' : undefined"
:aria-label="loading ? RELEASE_NOTES_TEXT.listLoading : undefined"
:data-release-notes-loading="loading ? '' : undefined"
>
<div
class="release-note-list__grid release-note-list__head"
aria-hidden="true"
>
<span class="release-note-list__head-title">{{ RELEASE_NOTES_TEXT.columnTitle }}</span>
<span class="release-note-list__head-date">{{ RELEASE_NOTES_TEXT.columnDate }}</span>
</div>
<div
v-if="loading"
class="release-note-list__skeleton"
>
<div
v-for="row in SKELETON_ROWS"
:key="row"
class="release-note-list__grid release-note-list__skeleton-row"
>
<span class="release-note-list__skeleton-bar release-note-list__skeleton-bar--title" />
<span class="release-note-list__skeleton-bar release-note-list__skeleton-bar--date" />
</div>
</div>
<ul
v-else
class="release-note-list__rows"
role="list"
:aria-labelledby="labelledby"
>
<li
v-for="note in notes"
:key="note.id"
class="release-note-list__item"
>
<RouterLink
:to="{ name: SETTINGS_ROUTES.releaseNote, params: { id: note.id } }"
class="release-note-list__grid release-note-list__row"
data-release-note-row
:data-release-note-id="note.id"
>
<span
class="release-note-list__title"
data-release-note-title
>{{ note.title }}</span>
<time
class="release-note-list__date"
:datetime="note.date"
data-release-note-date
>{{ formatCalendarDate(note.date) }}</time>
</RouterLink>
</li>
</ul>
</div>
</div>
</template>
<style scoped>
/* 이 칸의 폭이 좁은 폭 규칙의 기준이다 (보드 A3 — 목록 칸 480) */
.release-note-list {
container: release-note-list / inline-size;
}
.release-note-list__box {
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-lg);
overflow: hidden;
}
/* 칸: 제목(나머지) · 날짜 136 */
.release-note-list__grid {
display: grid;
grid-template-columns: minmax(0, 1fr) 8.5rem;
}
.release-note-list__head {
align-items: center;
height: var(--size-table-head);
border-bottom: var(--border-width) solid var(--color-border);
background: var(--color-surface-sunken);
font-size: var(--font-size-small);
font-weight: 600;
color: var(--color-text-muted);
}
.release-note-list__head-title {
padding-left: var(--space-16);
}
.release-note-list__head-date {
padding: 0 var(--space-12);
}
.release-note-list__rows {
margin: 0;
padding: 0;
list-style: none;
}
.release-note-list__item + .release-note-list__item,
.release-note-list__skeleton-row + .release-note-list__skeleton-row {
border-top: var(--border-width) solid var(--color-border-subtle);
}
/* 줄 — 위아래 13 + 글줄 21 = 47, 최소 48 */
.release-note-list__row {
align-items: baseline;
min-height: var(--space-48);
padding: 0.8125rem 0;
color: var(--color-text);
text-decoration: none;
}
.release-note-list__row:hover {
background: var(--color-row-hover);
color: var(--color-text);
}
/* 키보드 초점 — 줄 안쪽 2px 청록 (상자의 overflow 에 잘리지 않게 안쪽으로) */
.release-note-list__row:focus-visible {
outline-offset: calc(var(--focus-ring-width) * -1);
}
.release-note-list__title {
min-width: 0;
padding-left: var(--space-16);
font-size: var(--font-size-body);
line-height: var(--line-height-base);
font-weight: 500;
}
.release-note-list__date {
padding: 0 var(--space-12);
font-size: var(--font-size-label);
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
/* 불러오는 중 — 줄 자리 막대 (보드 A1 불러오는 중) */
.release-note-list__skeleton-row {
align-items: center;
min-height: var(--space-48);
}
.release-note-list__skeleton-bar {
display: block;
}
.release-note-list__skeleton-bar--title {
width: 54%;
height: var(--space-12);
margin-left: var(--space-16);
border-radius: var(--space-6);
background: var(--color-skeleton);
}
.release-note-list__skeleton-bar--date {
width: 6rem;
height: var(--space-10);
margin-left: var(--space-12);
border-radius: 0.3125rem;
background: var(--color-border-subtle);
}
/* 줄마다 길이를 조금씩 달리한다 (인라인 스타일 대신 차례로) */
.release-note-list__skeleton-row:nth-child(2) .release-note-list__skeleton-bar--title {
width: 38%;
}
.release-note-list__skeleton-row:nth-child(3) .release-note-list__skeleton-bar--title {
width: 62%;
}
.release-note-list__skeleton-row:nth-child(4) .release-note-list__skeleton-bar--title {
width: 46%;
}
/* 좁은 폭 (보드 A3) — 머리 줄을 감추고 날짜를 제목 아래로. 줄 전체를 누르는 것은 같다 */
@container release-note-list (width < 30rem) {
.release-note-list__head {
display: none;
}
.release-note-list__row {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-12) var(--space-16);
}
.release-note-list__title,
.release-note-list__date {
padding: 0;
}
}
</style>
@@ -0,0 +1,114 @@
<script setup lang="ts">
// 설정 › 정보 › 업데이트 내역 › 글 끝의 다른 글 줄 (디자인 원본 ReleaseNotes A2 · README "업데이트 내역 › 글")
// - 다음 글(바로 더 새 글)이 위 · 이전 글(바로 더 옛 글)이 아래 — 목록과 같은 차례. 없는 쪽의 줄은 두지 않는다(둘 다 없으면 이 칸째 두지 않는다 — 화면이 정한다)
// - 줄(48 이상)은 이름 64 · 제목 · 날짜 — 목록 줄처럼 줄 전체가 그 글로 가는 링크다(마우스 옅은 청록 · 키보드 초점 안쪽 2px)
// - 기록은 바꿔 끼운다 — 몇 글을 넘겨 봐도 뒤로 가기는 목록으로 간다(메일 읽기 화면의 이전 · 다음과 같다)
// - 칸이 480 보다 좁으면(컨테이너 쿼리) 날짜를 제목 아래로 내린다
// 사용 예: <ReleaseNoteNeighbors :neighbors="releaseNoteNeighbors(note)" />
import { RELEASE_NOTES_TEXT } from '@/shared/constants/release-notes'
import { SETTINGS_ROUTES } from '@/shared/constants/settings-menu'
import { formatCalendarDate } from '@/shared/utils/format'
import type { ReleaseNoteNeighbor, ReleaseNoteNeighborKind } from '@/shared/utils/release-notes'
interface Props {
neighbors: readonly ReleaseNoteNeighbor[]
}
defineProps<Props>()
const LABELS: Readonly<Record<ReleaseNoteNeighborKind, string>> = {
newer: RELEASE_NOTES_TEXT.newer,
older: RELEASE_NOTES_TEXT.older,
}
</script>
<template>
<nav
class="release-note-neighbors"
:aria-label="RELEASE_NOTES_TEXT.neighbors"
>
<RouterLink
v-for="neighbor in neighbors"
:key="neighbor.kind"
:to="{ name: SETTINGS_ROUTES.releaseNote, params: { id: neighbor.note.id } }"
replace
class="release-note-neighbors__row"
:data-release-note-neighbor="neighbor.kind"
>
<span class="release-note-neighbors__label">{{ LABELS[neighbor.kind] }}</span>
<span class="release-note-neighbors__title">{{ neighbor.note.title }}</span>
<time
class="release-note-neighbors__date"
:datetime="neighbor.note.date"
>{{ formatCalendarDate(neighbor.note.date) }}</time>
</RouterLink>
</nav>
</template>
<style scoped>
.release-note-neighbors {
container: release-note-neighbors / inline-size;
max-width: var(--content-measure);
display: flex;
flex-direction: column;
border-top: var(--border-width) solid var(--color-border);
border-bottom: var(--border-width) solid var(--color-border);
}
/* 줄 — 이름 64 · 제목(나머지) · 날짜, 사이 12 · 위아래 13 · 좌우 8 */
.release-note-neighbors__row {
display: grid;
grid-template-columns: 4rem minmax(0, 1fr) auto;
align-items: baseline;
gap: var(--space-12);
min-height: var(--space-48);
padding: 0.8125rem var(--space-8);
color: var(--color-text);
text-decoration: none;
}
.release-note-neighbors__row + .release-note-neighbors__row {
border-top: var(--border-width) solid var(--color-border-subtle);
}
.release-note-neighbors__row:hover {
background: var(--color-row-hover);
color: var(--color-text);
}
.release-note-neighbors__row:focus-visible {
outline-offset: calc(var(--focus-ring-width) * -1);
}
.release-note-neighbors__label {
font-size: var(--font-size-label);
font-weight: 600;
color: var(--color-text-muted);
}
.release-note-neighbors__title {
min-width: 0;
font-size: var(--font-size-body);
line-height: var(--line-height-base);
font-weight: 500;
}
.release-note-neighbors__date {
font-size: var(--font-size-label);
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
/* 좁은 폭 — 날짜를 제목 아래로 (이름 칸은 그대로) */
@container release-note-neighbors (width < 30rem) {
.release-note-neighbors__row {
grid-template-columns: 4rem minmax(0, 1fr);
row-gap: var(--space-2);
}
.release-note-neighbors__date {
grid-column: 2;
}
}
</style>
@@ -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<AxiosRequestConfig, 'silent' | 'signal'>
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<PaginatedResult<ReleaseNoteSummary>> {
const reply = await api.get<unknown, unknown>(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<ReleaseNote> {
const reply = await api.get<unknown, unknown>(`${RELEASE_NOTES_PATH}/${encodeURIComponent(id)}`, options)
const note = releaseNoteOf(reply)
if (note) return note
throw malformedReply(options)
}
return { listReleaseNotes, getReleaseNote }
}
@@ -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<typeof window.scrollTo>
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' })
})
})
@@ -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' })
}
@@ -0,0 +1,375 @@
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { createRouter, createWebHistory, type Router } from 'vue-router'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import type { ReleaseNote, ReleaseNoteSummary } from '@/shared/types/release-notes'
import { useAuthStore } from '@/stores/auth.store'
import { useToastStore } from '@/stores/toast.store'
import {
fail,
flushPromises,
installMockApi,
ok,
restoreApiAdapter,
type MockRequest,
type MockResult,
} from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
import InfoReleaseNotePage from './InfoReleaseNotePage.vue'
// 설정 › 정보 › 업데이트 내역 — 글 (디자인 원본 ReleaseNotes A2 · README "업데이트 내역 › 글")
// 맨 위 글 · 가운데 글(다른 글 줄) · 없는 글(404 RES_001 — 알림 없이 그 화면) · 받지 못함 — 어떤 상태에서도 위에 [← 목록으로] 줄
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
const LIST_PATH = '/settings/info/release-notes'
const TOP_SUMMARY: ReleaseNoteSummary = {
id: '2026-11-18',
date: '2026-11-18',
title: '메일 검색이 빨라지고 휴대폰 앱이 나왔습니다',
}
const MID_SUMMARY: ReleaseNoteSummary = { id: '2026-11-05', date: '2026-11-05', title: '화면의 안내 글을 다듬었습니다' }
const MENU_SUMMARY: ReleaseNoteSummary = { id: '2026-11-04', date: '2026-11-04', title: '왼쪽 메뉴를 정리했습니다' }
/** 맨 위 글 — 다음 글 없음 · 이전 글은 가운데 글 */
const TOP: ReleaseNote = {
...TOP_SUMMARY,
sections: [
{
heading: '휴대폰 앱',
items: [
'안드로이드 휴대폰 앱이 나왔습니다. 웹메일의 [설정] → [휴대폰 앱]에서 받을 수 있습니다.',
'앱에서 메일을 읽고 쓰고 찾을 수 있고, 새 메일이 오면 휴대폰으로 알려 드립니다. 알림에는 새 메일이 몇 통인지만 나오고, 보낸 사람이나 제목은 나오지 않습니다.',
],
},
{ heading: '메일 검색', items: ['찾은 말에는 노란 표시가 붙습니다.'] },
],
previous: MID_SUMMARY,
next: null,
}
/** 가운데 글 — 머리 없는 묶음 · 다음 글(맨 위 글) · 이전 글 */
const MID: ReleaseNote = {
...MID_SUMMARY,
sections: [
{
heading: null,
items: [
'단추와 안내 글을 더 알기 쉬운 말로 고쳤습니다.',
'브라우저 탭과 휴대폰 홈 화면의 아이콘을 ARA SYSTEM 마크로 바꿨습니다.',
],
},
],
previous: MENU_SUMMARY,
next: TOP_SUMMARY,
}
const NOTES: Record<string, ReleaseNote> = { [TOP.id]: TOP, [MID.id]: MID }
function noteReply(request: MockRequest): MockResult {
const id = decodeURIComponent(request.url.replace('/release-notes/', ''))
const note = NOTES[id]
return note ? ok(note) : fail(404, 'RES_001')
}
/** 응답을 손으로 보내는 요청 — 받는 동안의 화면을 본다 */
function deferredReply() {
let resolve: (result: MockResult) => void = () => {}
const promise = new Promise<MockResult>((settle) => {
resolve = settle
})
return { promise, resolve }
}
/**
* 좁은 화면(문서가 스크롤)을 꾸민다 — 문서를 scrolled 만큼 내린 채, 문서 맨 위에서 잰 글 칸(main)의 위 끝과 머리(head)의 아래 끝.
* jsdom 은 배치를 재지 못한다 — getBoundingClientRect 를 꾸민다(창 높이는 768)
*/
function narrowLayout(scrolled: number, mainTop: number, headBottom: number, head: string) {
document.documentElement.scrollTop = scrolled
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) {
if (this.matches('main')) return { top: mainTop - scrolled, bottom: 0 } as DOMRect
if (this.matches(head)) return { top: 0, bottom: headBottom - scrolled } as DOMRect
return { top: 0, bottom: 0 } as DOMRect
})
}
describe('InfoReleaseNotePage', () => {
let pinia: Pinia
let requests: MockRequest[]
let scrollTo: MockInstance<typeof window.scrollTo>
const mounted: VueWrapper[] = []
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated(USER)
scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => undefined)
})
afterEach(() => {
for (const wrapper of mounted.splice(0)) {
if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
}
vi.restoreAllMocks()
useToastStore().clear()
restoreApiAdapter()
document.documentElement.scrollTop = 0
document.body.innerHTML = ''
})
async function mountAt(
id: string,
reply: (request: MockRequest) => MockResult | Promise<MockResult> = noteReply,
router: Router = createTestRouter(),
) {
requests = installMockApi(reply)
await router.push(LIST_PATH)
await router.push(`${LIST_PATH}/${id}`)
await router.isReady()
const wrapper = mount(InfoReleaseNotePage, { attachTo: document.body, global: { plugins: [pinia, router] } })
mounted.push(wrapper)
await flushPromises()
return { wrapper, router }
}
const requestLines = () => requests.map((request) => `${request.method} ${request.url}`)
const neighborRows = (wrapper: VueWrapper) =>
wrapper.findAll('nav[aria-label="다른 글"] a').map((row) => ({
kind: row.attributes('data-release-note-neighbor'),
text: row.findAll('span, time').map((cell) => cell.text()),
href: row.attributes('href'),
}))
describe('글 (A2)', () => {
it('맨 위 글 — 날짜 · 제목(h1) · 묶음(머리 + 점 목록), 위에 [← 목록으로] 줄 · 끝에 [목록]', async () => {
const { wrapper } = await mountAt(TOP.id)
const main = wrapper.get('main')
const title = wrapper.get('h1')
expect(title.text()).toBe('메일 검색이 빨라지고 휴대폰 앱이 나왔습니다')
expect(main.attributes('aria-labelledby')).toBe(title.attributes('id'))
expect(main.attributes('aria-label')).toBeUndefined()
const date = wrapper.get('article time')
expect(date.text()).toBe('2026년 11월 18일')
expect(date.attributes('datetime')).toBe('2026-11-18')
const sections = wrapper.findAll('[data-release-note-section]')
expect(sections.map((section) => section.find('h2').text())).toEqual(['휴대폰 앱', '메일 검색'])
expect(sections[0]?.findAll('li').map((item) => item.text())).toEqual(TOP.sections[0]?.items)
const back = wrapper.get('a[data-release-note-back]')
expect(back.text()).toBe('목록으로')
expect(back.attributes('href')).toBe(LIST_PATH)
expect(back.classes()).toContain('tool-button--accent')
const list = wrapper.get('a[data-release-note-list]')
expect(list.text()).toBe('목록')
expect(list.attributes('href')).toBe(LIST_PATH)
expect(requestLines()).toEqual(['GET /release-notes/2026-11-18'])
expect(requests[0]?.config.silent).toBe(true)
})
it('맨 위 글이라 다음 글 줄은 없고 이전 글 줄만 — 줄 전체가 그 글로 가는 링크', async () => {
const { wrapper } = await mountAt(TOP.id)
expect(neighborRows(wrapper)).toEqual([
{
kind: 'older',
text: ['이전 글', '화면의 안내 글을 다듬었습니다', '2026년 11월 5일'],
href: `${LIST_PATH}/2026-11-05`,
},
])
})
it('가운데 글 — 다음 글(더 새 글)이 위 · 이전 글(더 옛 글)이 아래, 머리 없는 묶음은 점 목록만', async () => {
const { wrapper } = await mountAt(MID.id)
expect(neighborRows(wrapper)).toEqual([
{
kind: 'newer',
text: ['다음 글', '메일 검색이 빨라지고 휴대폰 앱이 나왔습니다', '2026년 11월 18일'],
href: `${LIST_PATH}/2026-11-18`,
},
{
kind: 'older',
text: ['이전 글', '왼쪽 메뉴를 정리했습니다', '2026년 11월 4일'],
href: `${LIST_PATH}/2026-11-04`,
},
])
const section = wrapper.get('[data-release-note-section]')
expect(section.find('h2').exists()).toBe(false)
expect(section.findAll('li')).toHaveLength(2)
})
it('글이 하나뿐이면 다른 글 칸째 없다 · [목록]은 그대로', async () => {
const { wrapper } = await mountAt(TOP.id, () => ok({ ...TOP, previous: null, next: null }))
expect(wrapper.find('nav[aria-label="다른 글"]').exists()).toBe(false)
expect(wrapper.find('a[data-release-note-list]').exists()).toBe(true)
})
it('다른 글 줄을 누르면 기록을 바꿔 끼우고 그 글을 받는다 — 누른 줄이 사라져 잃은 초점은 새 제목으로', async () => {
const { wrapper, router } = await mountAt(MID.id)
const newer = wrapper.get('a[data-release-note-neighbor="newer"]')
;(newer.element as HTMLElement).focus()
await newer.trigger('click', { button: 0 })
await flushPromises()
expect(router.currentRoute.value.params.id).toBe(TOP.id)
expect(requestLines()).toEqual(['GET /release-notes/2026-11-05', 'GET /release-notes/2026-11-18'])
const title = wrapper.get('h1')
expect(title.text()).toBe(TOP.title)
expect(document.activeElement).toBe(title.element)
// 바꿔 끼웠으니 뒤로 가기는 목록으로 간다
router.back()
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe(LIST_PATH)
})
})
describe('목록으로', () => {
it('[← 목록으로] · [목록] — 바로 앞 기록이 목록이 아니면 목록으로 바꿔 끼운다', async () => {
const { wrapper, router } = await mountAt(TOP.id)
await wrapper.get('a[data-release-note-list]').trigger('click', { button: 0 })
await flushPromises()
expect(router.currentRoute.value.name).toBe('settings-info-release-notes')
})
it('바로 앞 기록이 목록이면 뒤로 가기를 쓴다 (기록을 쌓지 않는다)', async () => {
// 메모리 history 는 history.state 를 쓰지 않으므로 실제 브라우저 history 로 확인한다
const router = createRouter({ history: createWebHistory(), routes: createTestRouter().options.routes })
const { wrapper } = await mountAt(TOP.id, noteReply, router)
await wrapper.get('a[data-release-note-back]').trigger('click', { button: 0 })
await vi.waitFor(() => expect(router.currentRoute.value.fullPath).toBe(LIST_PATH))
// 뒤로 간 것이라 앞으로 갈 기록(보던 글)이 남아 있다 — 바꿔 끼웠다면 없다
expect(router.options.history.state.forward).toBe(`${LIST_PATH}/${TOP.id}`)
})
})
describe('상태', () => {
it('받는 동안은 글 자리를 막대로 — 위의 [← 목록으로] 줄은 그대로', async () => {
const reply = deferredReply()
const { wrapper } = await mountAt(TOP.id, () => reply.promise)
const placeholder = wrapper.get('[data-release-note-loading]')
expect(placeholder.attributes('aria-busy')).toBe('true')
expect(placeholder.attributes('aria-label')).toBe('글 불러오는 중')
expect(wrapper.find('a[data-release-note-back]').exists()).toBe(true)
expect(wrapper.get('main').attributes('aria-label')).toBe('업데이트 내역')
reply.resolve(ok(TOP))
await flushPromises()
expect(wrapper.find('[data-release-note-loading]').exists()).toBe(false)
expect(wrapper.get('h1').text()).toBe(TOP.title)
})
it('없는 글(404 RES_001) — 알림 없이 사전 문구와 [목록으로], 위 줄은 그대로', async () => {
const { wrapper, router } = await mountAt('2026-01-01')
const error = wrapper.get('[data-release-note-error]')
expect(error.attributes('role')).toBe('alert')
expect(error.text()).toContain(ERROR_CODE_MESSAGES.RES_001)
expect(error.text()).toContain('문의 코드 3f1c0a2e')
expect(wrapper.find('a[data-release-note-back]').exists()).toBe(true)
expect(wrapper.find('article').exists()).toBe(false)
expect(useToastStore().toasts).toEqual([])
const leave = error.get('button')
expect(leave.text()).toBe('목록으로')
await leave.trigger('click')
await flushPromises()
expect(router.currentRoute.value.name).toBe('settings-info-release-notes')
})
it('글 id 꼴이 아닌 주소는 묻지 않고 없는 글 화면', async () => {
const { wrapper } = await mountAt('latest')
expect(requests).toEqual([])
const error = wrapper.get('[data-release-note-error]')
expect(error.text()).toContain(ERROR_CODE_MESSAGES.RES_001)
expect(error.get('button').text()).toBe('목록으로')
})
it('받지 못함 — 알림 없이 사전 문구 · [다시 시도] · 문의 코드, [다시 시도]는 같은 글을 다시 받는다', async () => {
let attempt = 0
const { wrapper } = await mountAt(TOP.id, () => {
attempt += 1
return attempt === 1 ? fail(502, 'SYS_001') : ok(TOP)
})
const error = wrapper.get('[data-release-note-error]')
expect(error.text()).toContain(ERROR_CODE_MESSAGES.SYS_001)
expect(error.text()).toContain('문의 코드 3f1c0a2e')
expect(useToastStore().toasts).toEqual([])
await error.get('button').trigger('click')
await flushPromises()
expect(requestLines()).toEqual(['GET /release-notes/2026-11-18', 'GET /release-notes/2026-11-18'])
expect(wrapper.get('h1').text()).toBe(TOP.title)
})
})
// 2026-11-10 실사용 시험 — 목록을 내린 채 글을 열면 앞 화면의 스크롤 자리를 이어받아 날짜 · 제목이 위로 지나가 있었다
describe('글의 맨 위가 보이게', () => {
it('받은 뒤에 맞춘다 — 문서를 맨 위로 올려도 제목이 보이면 맨 위로 (메뉴가 옆에 있는 폭 1000×600)', async () => {
narrowLayout(174, 57, 178, 'h1')
const reply = deferredReply()
await mountAt(TOP.id, () => reply.promise)
expect(scrollTo).not.toHaveBeenCalled()
reply.resolve(ok(TOP))
await flushPromises()
expect(scrollTo.mock.calls).toEqual([[{ top: 0, behavior: 'instant' }]])
})
it('위에 메뉴가 길어 맨 위로는 제목이 보이지 않으면 글 칸의 위 끝을 화면 위 끝에 (휴대폰 360×800)', async () => {
narrowLayout(1007, 906, 1027, 'h1')
await mountAt(TOP.id)
expect(scrollTo.mock.calls).toEqual([[{ top: 906, behavior: 'instant' }]])
})
it('없는 글이어도 그 안내가 보이게 맞춘다', async () => {
narrowLayout(1007, 906, 1240, '[data-release-note-error]')
const { wrapper } = await mountAt('2026-01-01')
expect(wrapper.find('[data-release-note-error]').exists()).toBe(true)
expect(scrollTo.mock.calls).toEqual([[{ top: 906, behavior: 'instant' }]])
})
it('문서가 맨 위에 있으면(처음 연 화면) 문서는 그대로 둔다', async () => {
await mountAt(TOP.id)
expect(scrollTo).not.toHaveBeenCalled()
})
it('다른 글로 옮기면 따로 스크롤하는 글 칸(넓은 화면)도 맨 위로 — 새 제목에는 스크롤 없이 초점만', async () => {
const { wrapper } = await mountAt(MID.id)
const main = wrapper.get('main').element as HTMLElement
main.scrollTop = 700
const focus = vi.spyOn(HTMLElement.prototype, 'focus')
const newer = wrapper.get('a[data-release-note-neighbor="newer"]')
;(newer.element as HTMLElement).focus()
await newer.trigger('click', { button: 0 })
await flushPromises()
const title = wrapper.get('h1')
expect(title.text()).toBe(TOP.title)
expect(main.scrollTop).toBe(0)
expect(document.activeElement).toBe(title.element)
const titleFocus = focus.mock.calls.filter((_, index) => focus.mock.contexts[index] === title.element)
expect(titleFocus).toEqual([[{ preventScroll: true }]])
})
})
})
@@ -0,0 +1,274 @@
<script setup lang="ts">
// 설정 › 정보 › 업데이트 내역 — 글 (디자인 원본 ReleaseNotes A2 · A3 · README "업데이트 내역") — /settings/info/release-notes/{id} · 탭 제목 "업데이트 내역"
// id 는 글 파일 이름(예 2026-11-18). 글을 보는 동안에도 설정 메뉴의 업데이트 내역이 고른 상태다(라우트를 목록 아래에 묶었다 — routes.ts)
// - 위: [← 목록으로] 줄(52 — 목록 보기에서 연 메일의 줄과 같은 꼴 · 스크롤해도 위에 남는다). 어떤 상태에서도 둔다
// - 글: 날짜 · 제목(h1) · 묶음(ReleaseNoteArticle) → 다른 글 줄(ReleaseNoteNeighbors — 없는 쪽은 줄이 없고, 둘 다 없으면 칸째 없다) → [목록](44)
// - 받는 동안은 글 자리를 막대로. 없는 글(404 RES_001 · 글 id 꼴이 아닌 주소): 사전 문구 + [목록으로], 그 밖의 실패: 사전 문구 + [다시 시도] — 알림 없음
// - [← 목록으로] · [목록] · 없는 글의 [목록으로]는 바로 앞 기록이 목록이면 뒤로 가기, 아니면 목록으로 바꿔 끼운다(기록을 쌓지 않는다 — 메일의 목록으로와 같다).
// 새 탭 · 새 창으로 여는 누름(Ctrl · ⌘ · Shift · 가운데 단추)은 브라우저에 맡긴다
// - 글을 열거나 다른 글로 옮기면(같은 화면이 주소만 바뀐다) 받은 뒤 글의 맨 위(날짜 · 제목)가 보이게 스크롤을 맞춘다 — 앞 화면의 스크롤 자리를 이어받지 않게
// (revealSettingsPageTop — 넓은 화면은 글 칸, 좁은 화면은 문서). 다른 글로 옮겨 누른 줄이 사라져 초점을 잃었으면 새 제목으로 옮긴다
// - 위에 붙은 [← 목록으로] 줄 밑에 초점이 가리지 않게 글 칸의 제목 · 링크 · 단추는 줄 높이만큼 띄워 스크롤한다(scroll-margin-top)
import { computed, nextTick, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import MailLoadError from '@/components/mail/MailLoadError.vue'
import MailToolButton from '@/components/mail/MailToolButton.vue'
import ReleaseNoteArticle from '@/components/settings/ReleaseNoteArticle.vue'
import ReleaseNoteNeighbors from '@/components/settings/ReleaseNoteNeighbors.vue'
import { useMailNavigation } from '@/composables/useMailNavigation'
import { revealSettingsPageTop } from '@/composables/useSettingsPageScroll'
import { RELEASE_NOTES_TEXT } from '@/shared/constants/release-notes'
import { SETTINGS_ROUTES } from '@/shared/constants/settings-menu'
import { releaseNoteNeighbors } from '@/shared/utils/release-notes'
import { useReleaseNotesStore } from '@/stores/release-notes.store'
const TITLE_ID = 'release-note-title'
const LIST_LOCATION = { name: SETTINGS_ROUTES.releaseNotes }
const route = useRoute()
const router = useRouter()
const releaseNotesStore = useReleaseNotesStore()
const { backOrReplace } = useMailNavigation()
const mainRef = ref<HTMLElement | null>(null)
const articleRef = ref<InstanceType<typeof ReleaseNoteArticle> | null>(null)
/** 주소의 글 id — 이 화면의 라우트일 때만(떠나는 사이 바뀐 주소로 다시 받지 않게) */
const noteId = computed<string | null>(() => {
const id = route.params.id
return route.name === SETTINGS_ROUTES.releaseNote && typeof id === 'string' ? id : null
})
const listHref = computed(() => router.resolve(LIST_LOCATION).href)
const note = computed(() => (releaseNotesStore.noteStatus === 'ready' ? releaseNotesStore.note : null))
const neighbors = computed(() => (note.value ? releaseNoteNeighbors(note.value) : []))
const failure = computed(() =>
releaseNotesStore.noteStatus === 'missing' || releaseNotesStore.noteStatus === 'error'
? releaseNotesStore.noteError
: null,
)
/** 누른 줄 · 단추가 사라져 초점을 잃었는지 */
function focusLost(): boolean {
const active = document.activeElement
return active === null || active === document.body || !active.isConnected
}
function backToList() {
void backOrReplace(LIST_LOCATION)
}
/** 목록으로 가는 링크 — 새 탭 · 새 창으로 여는 누름은 브라우저에 맡긴다 */
function onListLink(event: MouseEvent) {
if (event.ctrlKey || event.metaKey || event.shiftKey || event.altKey || event.button !== 0) return
event.preventDefault()
backToList()
}
function retry() {
if (noteId.value !== null) void releaseNotesStore.loadNote(noteId.value)
}
/**
* 글을 받고 그린 뒤 글의 맨 위가 보이게 맞춘다(없는 글 · 받지 못함이면 그 안내가 보이게).
* 다른 글에서 옮겨 왔고(moved) 누른 줄이 사라져 초점을 잃었으면 새 제목으로 — 스크롤은 위에서 맞췄으니 초점만 옮긴다
*/
async function open(id: string, moved: boolean) {
await releaseNotesStore.loadNote(id)
// 그사이 다른 글로 옮겼으면 그 글이 맞춘다 · 화면을 떠났으면 할 일이 없다
if (noteId.value !== id) return
await nextTick()
const main = mainRef.value
if (!main) return
revealSettingsPageTop(main, main.querySelector<HTMLElement>('h1, [data-release-note-error]') ?? main)
if (moved && releaseNotesStore.noteStatus === 'ready' && focusLost()) articleRef.value?.focusTitle()
}
// 처음 그리기 전에 받기 시작한다 — 지난번에 받아 둔 글을 잠깐 그렸다 바꾸지 않게. 같은 화면에서 주소의 id 만 바뀌어도 다시 받는다
watch(
noteId,
(id, previous) => {
if (id !== null) void open(id, typeof previous === 'string')
},
{ immediate: true },
)
</script>
<template>
<main
ref="mainRef"
class="release-note"
:aria-labelledby="note ? TITLE_ID : undefined"
:aria-label="note ? undefined : RELEASE_NOTES_TEXT.title"
>
<div class="release-note__bar">
<div class="release-note__bar-row">
<MailToolButton
icon="arrow-left"
:label="RELEASE_NOTES_TEXT.backToList"
tone="accent"
:href="listHref"
:icon-stroke-width="1.9"
data-release-note-back
@click="onListLink"
/>
</div>
</div>
<div class="release-note__column">
<MailLoadError
v-if="failure"
:error="failure"
:leave-label="RELEASE_NOTES_TEXT.backToList"
data-release-note-error
@retry="retry"
@leave="backToList"
/>
<template v-else-if="note">
<ReleaseNoteArticle
ref="articleRef"
:note="note"
:title-id="TITLE_ID"
/>
<ReleaseNoteNeighbors
v-if="neighbors.length > 0"
:neighbors="neighbors"
/>
<div class="release-note__actions">
<a
:href="listHref"
class="release-note__list-link"
data-release-note-list
@click="onListLink"
>{{ RELEASE_NOTES_TEXT.list }}</a>
</div>
</template>
<div
v-else
class="release-note__placeholder"
aria-busy="true"
:aria-label="RELEASE_NOTES_TEXT.noteLoading"
data-release-note-loading
>
<span class="release-note__placeholder-bar release-note__placeholder-bar--date" />
<span class="release-note__placeholder-bar release-note__placeholder-bar--title" />
<span class="release-note__placeholder-bar release-note__placeholder-bar--heading" />
<span class="release-note__placeholder-bar" />
<span class="release-note__placeholder-bar release-note__placeholder-bar--short" />
</div>
</div>
</main>
</template>
<style scoped>
/* 설정 화면의 틀(SettingsPageFrame)과 같은 칸 — 위에 [← 목록으로] 줄이 하나 더 있다 */
.release-note {
flex: 999 1 35rem;
min-width: 0;
display: flex;
flex-direction: column;
word-break: keep-all;
overflow-wrap: anywhere;
}
/* [← 목록으로] 줄 — 칸 전체 폭으로 아래 선을 긋고, 스크롤해도 위에 남는다 (읽기 화면의 도구 줄과 같다) */
.release-note__bar {
position: sticky;
top: 0;
z-index: 1;
border-bottom: var(--border-width) solid var(--color-border);
background: var(--color-surface);
}
/* 콘텐츠 열 안 — 안쪽 22 + 단추 안쪽 10 = 아이콘이 32 선 */
.release-note__bar-row {
display: flex;
align-items: center;
max-width: var(--content-max-width);
min-height: var(--size-header-min);
padding: var(--space-4) var(--content-inline) var(--space-4) var(--content-toolbar-start);
}
.release-note__column {
max-width: var(--content-max-width);
display: flex;
flex-direction: column;
gap: var(--space-22);
padding: var(--content-block-start) var(--content-inline) var(--content-block-end);
}
/*
* 위에 붙은 [← 목록으로] 줄(52 + 선 1) 밑에 초점이 가리지 않게 (WCAG 2.4.11) — 초점을 옮기며 스크롤할 때 줄 높이 + 8 만큼 띄운다.
* 스크롤하는 것이 글 칸이든 문서든 같다
*/
.release-note__column :deep(:is(h1, a, button, [tabindex])) {
scroll-margin-top: calc(var(--size-header-min) + var(--border-width) + var(--space-8));
}
.release-note__actions {
display: flex;
}
/* [목록] 44 — 흰 바탕 · 테두리 */
.release-note__list-link {
display: inline-flex;
align-items: center;
min-height: var(--size-touch);
padding: 0 var(--space-18);
border: var(--border-width) solid var(--color-border-strong);
border-radius: var(--radius-md);
background: var(--color-surface);
color: var(--color-text-strong);
font-weight: 600;
text-decoration: none;
}
.release-note__list-link:hover {
background: var(--color-surface-muted);
color: var(--color-text-strong);
}
/* 받는 동안 — 날짜 · 제목 · 묶음 자리 (목록의 막대와 같은 결) */
.release-note__placeholder {
display: flex;
flex-direction: column;
gap: var(--space-12);
}
.release-note__placeholder-bar {
width: 80%;
max-width: var(--content-measure);
height: var(--space-10);
border-radius: var(--radius-xs);
background: var(--color-border-subtle);
}
.release-note__placeholder-bar--date {
width: 7.5rem;
background: var(--color-skeleton);
}
.release-note__placeholder-bar--title {
width: 55%;
height: var(--space-18);
margin-bottom: var(--space-12);
background: var(--color-skeleton);
}
.release-note__placeholder-bar--heading {
width: 20%;
height: var(--space-12);
background: var(--color-skeleton);
}
.release-note__placeholder-bar--short {
width: 45%;
}
/* 넓은 화면에서는 이 칸만 따로 스크롤한다 (설정 화면의 틀과 같다) */
@media (min-width: 70rem) {
.release-note {
overflow-y: auto;
}
}
</style>
@@ -0,0 +1,284 @@
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import type { Router } from 'vue-router'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import type { ReleaseNoteSummary } from '@/shared/types/release-notes'
import { useAuthStore } from '@/stores/auth.store'
import { useToastStore } from '@/stores/toast.store'
import {
fail,
flushPromises,
installMockApi,
ok,
restoreApiAdapter,
type MockRequest,
type MockResult,
} from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
import InfoReleaseNotesPage from './InfoReleaseNotesPage.vue'
// 설정 › 정보 › 업데이트 내역 — 목록 (디자인 원본 ReleaseNotes A1 · README "업데이트 내역 › 목록")
// 글이 있을 때(머리 줄 · 줄 전체 링크 · [더 보기]) · 불러오는 중(막대) · 받지 못함(사전 문구 · [다시 시도] · 문의 코드) · 글이 없을 때
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
/** 보드의 넷 — 맨 위 글은 다음 배포의 첫 글 초안 */
const BOARD_NOTES: ReleaseNoteSummary[] = [
{ id: '2026-11-18', date: '2026-11-18', title: '메일 검색이 빨라지고 휴대폰 앱이 나왔습니다' },
{ id: '2026-11-05', date: '2026-11-05', title: '화면의 안내 글을 다듬었습니다' },
{ id: '2026-11-04', date: '2026-11-04', title: '왼쪽 메뉴를 정리했습니다' },
{ id: '2026-11-03', date: '2026-11-03', title: '2단계 인증과 앱 비밀번호를 쓸 수 있습니다' },
]
/** 20개를 넘는 목록 — 첫 쪽 20 · 둘째 쪽 2 */
const MANY = Array.from({ length: 22 }, (_, index): ReleaseNoteSummary => {
const id = `2026-10-${String(31 - index).padStart(2, '0')}`
return { id, date: id, title: `${31 - index}일의 업데이트` }
})
const lastPage = (items: ReleaseNoteSummary[]) => ({ items, pageInfo: { hasNext: false, nextCursor: null, total: items.length } })
/** 응답을 손으로 보내는 요청 — 받는 동안의 화면을 본다 */
function deferredReply() {
let resolve: (result: MockResult) => void = () => {}
const promise = new Promise<MockResult>((settle) => {
resolve = settle
})
return { promise, resolve }
}
describe('InfoReleaseNotesPage', () => {
let pinia: Pinia
let requests: MockRequest[]
let router: Router
let scrollTo: MockInstance<typeof window.scrollTo>
const mounted: VueWrapper[] = []
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated(USER)
scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => undefined)
})
afterEach(() => {
for (const wrapper of mounted.splice(0)) {
if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
}
vi.restoreAllMocks()
useToastStore().clear()
restoreApiAdapter()
document.documentElement.scrollTop = 0
document.body.innerHTML = ''
})
async function mountPage(reply: (request: MockRequest) => MockResult | Promise<MockResult>) {
requests = installMockApi(reply)
router = createTestRouter()
await router.push('/settings/info/release-notes')
await router.isReady()
const wrapper = mount(InfoReleaseNotesPage, { attachTo: document.body, global: { plugins: [pinia, router] } })
mounted.push(wrapper)
await flushPromises()
return wrapper
}
const requestLines = () => requests.map((request) => `${request.method} ${request.url}`)
describe('글이 있을 때 (A1)', () => {
it('머리 · 설명 한 줄 — 목록은 머리 줄(제목 · 날짜) 아래 최근 글부터 한 줄씩, 날짜는 "2026년 11월 18일"', async () => {
const wrapper = await mountPage(() => ok(lastPage(BOARD_NOTES)))
expect(wrapper.get('h1').text()).toBe('업데이트 내역')
expect(wrapper.text()).toContain('웹메일과 휴대폰 앱에 새로 생기거나 바뀐 것을 알려 드립니다.')
const head = wrapper.get('.release-note-list__head')
expect(head.attributes('aria-hidden')).toBe('true')
expect(head.findAll('span').map((cell) => cell.text())).toEqual(['제목', '날짜'])
const rows = wrapper.findAll('a[data-release-note-row]')
expect(rows.map((row) => row.get('[data-release-note-title]').text())).toEqual(BOARD_NOTES.map((note) => note.title))
expect(rows.map((row) => row.get('[data-release-note-date]').text())).toEqual([
'2026년 11월 18일',
'2026년 11월 5일',
'2026년 11월 4일',
'2026년 11월 3일',
])
expect(rows[0]?.get('time').attributes('datetime')).toBe('2026-11-18')
expect(requestLines()).toEqual(['GET /release-notes'])
expect(requests[0]?.params).toEqual({ limit: 20 })
})
it('목록 이름은 화면 제목이고, 줄 전체가 그 글로 가는 링크다', async () => {
const wrapper = await mountPage(() => ok(lastPage(BOARD_NOTES)))
const list = wrapper.get('ul[role="list"]')
expect(list.attributes('aria-labelledby')).toBe(wrapper.get('h1').attributes('id'))
const first = list.get('a[data-release-note-row]')
expect(first.attributes('href')).toBe('/settings/info/release-notes/2026-11-18')
await first.trigger('click', { button: 0 })
await flushPromises()
expect(router.currentRoute.value.name).toBe('settings-info-release-note')
expect(router.currentRoute.value.params.id).toBe('2026-11-18')
})
it('20개뿐이면 [더 보기]가 없다', async () => {
const wrapper = await mountPage(() => ok(lastPage(MANY.slice(0, 20))))
expect(wrapper.findAll('a[data-release-note-row]')).toHaveLength(20)
expect(wrapper.find('[data-release-notes-more]').exists()).toBe(false)
})
})
describe('[더 보기]', () => {
function pagedReply(second: () => MockResult | Promise<MockResult>) {
return (request: MockRequest) =>
request.params.cursor === 'c20'
? second()
: ok({ items: MANY.slice(0, 20), pageInfo: { hasNext: true, nextCursor: 'c20', total: 22 } })
}
it('20개씩 이어 받는다 — 받는 동안 그 단추만 바쁨, 마지막 쪽이면 단추가 사라지고 초점은 새로 붙은 첫 줄로', async () => {
const second = deferredReply()
const wrapper = await mountPage(pagedReply(() => second.promise))
const more = wrapper.get('button[data-release-notes-more]')
expect(more.text()).toBe('더 보기')
;(more.element as HTMLButtonElement).focus()
await more.trigger('click')
expect(more.attributes('aria-busy')).toBe('true')
expect(more.attributes('aria-disabled')).toBe('true')
expect(wrapper.findAll('a[data-release-note-row]')).toHaveLength(20)
second.resolve(ok(lastPage(MANY.slice(20))))
await flushPromises()
const rows = wrapper.findAll('a[data-release-note-row]')
expect(rows).toHaveLength(22)
expect(wrapper.find('[data-release-notes-more]').exists()).toBe(false)
expect(document.activeElement).toBe(rows[20]?.element)
expect(requests.map((request) => request.params)).toEqual([{ limit: 20 }, { cursor: 'c20', limit: 20 }])
})
it('더 받지 못하면 알림으로 알리고 받은 줄 · 단추 · 초점은 그대로', async () => {
const wrapper = await mountPage(pagedReply(() => fail(503, 'SYS_001')))
const more = wrapper.get('button[data-release-notes-more]')
;(more.element as HTMLButtonElement).focus()
await more.trigger('click')
await flushPromises()
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([ERROR_CODE_MESSAGES.SYS_001])
expect(wrapper.findAll('a[data-release-note-row]')).toHaveLength(20)
expect(more.attributes('aria-busy')).toBeUndefined()
expect(document.activeElement).toBe(more.element)
})
})
it('불러오는 중 — 머리 줄은 두고 줄 자리를 막대 넷으로, [더 보기]는 없다', async () => {
const reply = deferredReply()
const wrapper = await mountPage(() => reply.promise)
const box = wrapper.get('[data-release-notes-loading]')
expect(box.attributes('aria-busy')).toBe('true')
expect(box.attributes('aria-label')).toBe('업데이트 내역 불러오는 중')
expect(box.find('.release-note-list__head').exists()).toBe(true)
expect(box.findAll('.release-note-list__skeleton-row')).toHaveLength(4)
expect(wrapper.find('a[data-release-note-row]').exists()).toBe(false)
expect(wrapper.find('[data-release-notes-more]').exists()).toBe(false)
reply.resolve(ok(lastPage(BOARD_NOTES)))
await flushPromises()
expect(wrapper.find('[data-release-notes-loading]').exists()).toBe(false)
expect(wrapper.findAll('a[data-release-note-row]')).toHaveLength(4)
})
it('받지 못함 — 표 자리에 사전 문구 · [다시 시도] · 문의 코드, 알림은 띄우지 않는다. [다시 시도]는 다시 받는다', async () => {
let attempt = 0
const wrapper = await mountPage(() => {
attempt += 1
return attempt === 1 ? fail(503, 'SYS_001') : ok(lastPage(BOARD_NOTES))
})
const error = wrapper.get('[data-release-notes-error]')
expect(error.attributes('role')).toBe('alert')
expect(error.text()).toContain(ERROR_CODE_MESSAGES.SYS_001)
expect(error.text()).toContain('문의 코드 3f1c0a2e')
expect(wrapper.find('.release-note-list').exists()).toBe(false)
expect(useToastStore().toasts).toEqual([])
await error.get('button').trigger('click')
await flushPromises()
expect(requestLines()).toEqual(['GET /release-notes', 'GET /release-notes'])
expect(wrapper.findAll('a[data-release-note-row]')).toHaveLength(4)
})
it('목록이 404 여도 빠져나갈 곳이 없어 [다시 시도]다', async () => {
const wrapper = await mountPage(() => fail(404, 'RES_001'))
const error = wrapper.get('[data-release-notes-error]')
expect(error.text()).toContain(ERROR_CODE_MESSAGES.RES_001)
expect(error.get('button').text()).toBe('다시 시도')
await error.get('button').trigger('click')
await flushPromises()
expect(requestLines()).toEqual(['GET /release-notes', 'GET /release-notes'])
})
it('글이 없을 때 — 표와 [더 보기] 없이 한 줄만', async () => {
const wrapper = await mountPage(() => ok(lastPage([])))
const empty = wrapper.get('[data-release-notes-empty]')
expect(empty.text()).toBe('아직 알려 드릴 업데이트가 없습니다.')
expect(empty.attributes('role')).toBeUndefined()
expect(wrapper.find('.release-note-list').exists()).toBe(false)
expect(wrapper.find('[data-release-notes-more]').exists()).toBe(false)
})
// 2026-11-10 실사용 시험 — 좁은 폭에서 글에서 돌아오면 앞 화면의 스크롤 자리를 이어받아 목록의 맨 아래 근처가 보였다
it('받은 뒤 목록의 맨 위가 보이게 맞춘다 — 위에 메뉴가 길면 목록 칸의 위 끝을 화면 위 끝에, [더 보기] 뒤에는 맞추지 않는다', async () => {
// 휴대폰 360×800 — 문서를 1007 내린 채, 문서 맨 위에서 목록 칸은 906 · 제목 아래 끝은 931 (창 높이 768 을 넘는다)
document.documentElement.scrollTop = 1007
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) {
if (this.matches('main')) return { top: 906 - 1007, bottom: 0 } as DOMRect
if (this.matches('h1')) return { top: 0, bottom: 931 - 1007 } as DOMRect
return { top: 0, bottom: 0 } as DOMRect
})
const second = deferredReply()
const wrapper = await mountPage((request) =>
request.params.cursor === 'c20'
? second.promise
: ok({ items: MANY.slice(0, 20), pageInfo: { hasNext: true, nextCursor: 'c20', total: 22 } }),
)
expect(scrollTo.mock.calls).toEqual([[{ top: 906, behavior: 'instant' }]])
await wrapper.get('button[data-release-notes-more]').trigger('click')
second.resolve(ok(lastPage(MANY.slice(20))))
await flushPromises()
expect(wrapper.findAll('a[data-release-note-row]')).toHaveLength(22)
expect(scrollTo).toHaveBeenCalledTimes(1)
})
it('문서가 맨 위에 있으면(처음 연 화면) 문서는 그대로 두고, 따로 스크롤하는 칸(넓은 화면)은 [다시 시도]로 받은 뒤에도 맨 위로', async () => {
let attempt = 0
const wrapper = await mountPage(() => {
attempt += 1
return attempt === 1 ? fail(503, 'SYS_001') : ok(lastPage(BOARD_NOTES))
})
const main = wrapper.get('main').element as HTMLElement
main.scrollTop = 300
await wrapper.get('[data-release-notes-error] button').trigger('click')
await flushPromises()
expect(wrapper.findAll('a[data-release-note-row]')).toHaveLength(4)
expect(main.scrollTop).toBe(0)
expect(scrollTo).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,133 @@
<script setup lang="ts">
// 설정 › 정보 › 업데이트 내역 — 목록 (디자인 원본 ReleaseNotes A1 · A3 · README "업데이트 내역") — /settings/info/release-notes · 탭 제목 "업데이트 내역"
// 머리 "업데이트 내역" + 설명 한 줄 → 목록 칸(ReleaseNoteList — 최근 글부터, 줄을 누르면 그 글) → 더 있으면 [더 보기](44)
// - 들어올 때마다 새로 받는다(release-notes.store). 받는 동안은 머리 줄 + 막대 넷. 받은 뒤 목록의 맨 위가 보이게 스크롤을 맞춘다 —
// 글에서 돌아왔을 때 앞 화면의 스크롤 자리를 이어받지 않게(revealSettingsPageTop — 넓은 화면은 글 칸, 좁은 화면은 문서). [더 보기] 뒤에는 맞추지 않는다
// - 받지 못함: 칸 자리에 사전 문구 · [다시 시도] · 문의 코드(MailLoadError — 알림 없음). 목록은 빠져나갈 곳이 없어 없는 주소(404)여도 [다시 시도]
// - 글이 없음: "아직 알려 드릴 업데이트가 없습니다." 한 줄(표 · [더 보기] 없이)
// - [더 보기]: 20개씩 이어 받는다 — 받는 동안 그 단추만 바쁨(aria-busy · aria-disabled), 받지 못하면(응답의 모양이 틀려도) 알리고 받은 줄은 그대로.
// 마지막 쪽을 받아 단추가 사라지며 초점을 잃었으면 새로 붙은 첫 줄로 옮긴다
import { nextTick, ref } from 'vue'
import StateMessage from '@/components/feedback/StateMessage.vue'
import MailLoadError from '@/components/mail/MailLoadError.vue'
import ReleaseNoteList from '@/components/settings/ReleaseNoteList.vue'
import SettingsPageFrame from '@/components/settings/SettingsPageFrame.vue'
import { revealSettingsPageTop } from '@/composables/useSettingsPageScroll'
import { RELEASE_NOTES_TEXT } from '@/shared/constants/release-notes'
import { useReleaseNotesStore } from '@/stores/release-notes.store'
const TITLE_ID = 'release-notes-page-title'
const releaseNotesStore = useReleaseNotesStore()
const frameRef = ref<InstanceType<typeof SettingsPageFrame> | null>(null)
const listRef = ref<InstanceType<typeof ReleaseNoteList> | null>(null)
/** 누른 단추가 사라져 초점을 잃었는지 */
function focusLost(): boolean {
const active = document.activeElement
return active === null || active === document.body || !active.isConnected
}
/** 첫 쪽부터 받고, 그린 뒤 목록의 맨 위(제목)가 보이게 맞춘다 — 화면을 떠났으면 그대로 */
async function reload() {
await releaseNotesStore.loadList()
await nextTick()
const main: unknown = frameRef.value?.$el
if (!(main instanceof HTMLElement)) return
revealSettingsPageTop(main, main.querySelector<HTMLElement>('h1') ?? main)
}
async function loadMore() {
const firstAdded = await releaseNotesStore.loadMore()
if (firstAdded === null) return
await nextTick()
if (focusLost()) listRef.value?.focusRow(firstAdded)
}
// 처음 그리기 전에 — 지난번에 받아 둔 목록을 잠깐 그렸다 바꾸지 않게
void reload()
</script>
<template>
<SettingsPageFrame
ref="frameRef"
:title-id="TITLE_ID"
:title="RELEASE_NOTES_TEXT.title"
:description="RELEASE_NOTES_TEXT.description"
>
<MailLoadError
v-if="releaseNotesStore.listStatus === 'error' && releaseNotesStore.listError"
:error="releaseNotesStore.listError"
:leave-label="RELEASE_NOTES_TEXT.retry"
data-release-notes-error
@retry="reload"
@leave="reload"
/>
<StateMessage
v-else-if="releaseNotesStore.listStatus === 'ready' && releaseNotesStore.notes.length === 0"
icon="file-text"
:title="RELEASE_NOTES_TEXT.empty"
data-release-notes-empty
/>
<div
v-else
class="release-notes"
>
<ReleaseNoteList
ref="listRef"
:notes="releaseNotesStore.notes"
:labelledby="TITLE_ID"
:loading="releaseNotesStore.listStatus !== 'ready'"
/>
<div
v-if="releaseNotesStore.hasMore"
class="release-notes__more"
>
<button
type="button"
class="release-notes__more-button"
:aria-disabled="releaseNotesStore.loadingMore ? 'true' : undefined"
:aria-busy="releaseNotesStore.loadingMore ? 'true' : undefined"
data-release-notes-more
@click="loadMore"
>
{{ RELEASE_NOTES_TEXT.more }}
</button>
</div>
</div>
</SettingsPageFrame>
</template>
<style scoped>
.release-notes {
display: flex;
flex-direction: column;
gap: var(--space-16);
}
.release-notes__more {
display: flex;
}
/* [더 보기] 44 — 흰 바탕 · 테두리 (로그인 기록의 [더 보기]와 같다) */
.release-notes__more-button {
min-height: var(--size-touch);
padding: 0 var(--space-16);
border: var(--border-width) solid var(--color-border-strong);
border-radius: var(--radius-md);
background: var(--color-surface);
color: var(--color-text-strong);
font-weight: 600;
}
.release-notes__more-button:hover:not([aria-disabled='true']) {
background: var(--color-surface-muted);
}
.release-notes__more-button[aria-disabled='true'] {
opacity: 0.6;
cursor: progress;
}
</style>
@@ -6,12 +6,14 @@ import { createMemoryHistory, createRouter } from 'vue-router'
import { routes } from '@/router/routes'
import { useAuthStore } from '@/stores/auth.store'
import { flushPromises } from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
import SettingsLayout from './SettingsLayout.vue'
// 설정 메뉴 — 계정 › 비밀번호 · 2단계 인증 · 앱 비밀번호 · 메일 프로그램 연결 · 휴대폰 앱 · 로그인 기록 / 메일 › 서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 · 차단한 주소 · 대용량 첨부 · 알림
// / 정보 › 업데이트 내역
// (디자인 README "4단계 › 설정 메뉴" · "5단계" · "6단계" · "9단계 › 설정 · 보내기 · 읽기 · 차단한 주소" · "11단계 › 설정 › 메일 › 대용량 첨부" · "12단계 — 계정 보안" ·
// "13단계" · "휴대폰 앱 받기")
// "13단계" · "휴대폰 앱 받기" · "업데이트 내역")
describe('SettingsLayout', () => {
let pinia: Pinia
@@ -36,13 +38,17 @@ describe('SettingsLayout', () => {
return { wrapper, router }
}
it('묶음 둘 — 계정(비밀번호 · 2단계 인증 · 앱 비밀번호 · 메일 프로그램 연결 · 휴대폰 앱 · 로그인 기록) · 메일(서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 · 차단한 주소 · 대용량 첨부 · 알림), 묶음 이름이 목록의 이름이다', async () => {
it('묶음 셋 — 계정(비밀번호 · 2단계 인증 · 앱 비밀번호 · 메일 프로그램 연결 · 휴대폰 앱 · 로그인 기록) · 메일(서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 · 차단한 주소 · 대용량 첨부 · 알림) · 정보(업데이트 내역), 묶음 이름이 목록의 이름이다', async () => {
const { wrapper } = await mountAt('/settings/account/password')
const menu = wrapper.get('nav[aria-label="설정 메뉴"]')
expect(menu.findAll('.settings-menu__group').map((group) => group.text())).toEqual(['계정', '메일'])
expect(menu.findAll('.settings-menu__group').map((group) => group.text())).toEqual(['계정', '메일', '정보'])
const lists = menu.findAll('ul')
expect(lists.map((list) => list.attributes('aria-labelledby'))).toEqual(['settings-menu-account', 'settings-menu-mail'])
expect(lists.map((list) => list.attributes('aria-labelledby'))).toEqual([
'settings-menu-account',
'settings-menu-mail',
'settings-menu-info',
])
expect(lists.map((list) => list.findAll('a').map((link) => [link.text(), link.attributes('href')]))).toEqual([
[
['비밀번호', '/settings/account/password'],
@@ -61,9 +67,69 @@ describe('SettingsLayout', () => {
['대용량 첨부', '/settings/mail/large-files'],
['알림', '/settings/mail/notify'],
],
[['업데이트 내역', '/settings/info/release-notes']],
])
})
it('설정 › 정보 › 업데이트 내역 — 목록 /settings/info/release-notes · 글 /settings/info/release-notes/<글 id>, 로그인이 필요하고 탭 제목은 둘 다 "업데이트 내역" (앱의 라우트 표 그대로)', async () => {
const router = createRouter({ history: createMemoryHistory(), routes })
await router.push('/settings/info/release-notes')
expect(router.currentRoute.value.name).toBe('settings-info-release-notes')
expect(router.currentRoute.value.meta).toMatchObject({ title: '업데이트 내역', requiresAuth: true })
await router.push('/settings/info/release-notes/2026-11-18-2')
expect(router.currentRoute.value.name).toBe('settings-info-release-note')
expect(router.currentRoute.value.params.id).toBe('2026-11-18-2')
expect(router.currentRoute.value.meta).toMatchObject({ title: '업데이트 내역', requiresAuth: true })
expect(router.resolve({ name: 'settings-info-release-notes' }).fullPath).toBe('/settings/info/release-notes')
expect(router.resolve({ name: 'settings-info-release-note', params: { id: '2026-11-18' } }).fullPath).toBe(
'/settings/info/release-notes/2026-11-18',
)
})
it('/settings/info 는 업데이트 내역 목록으로 바꿔 끼운다 — 글 주소의 ".." 를 브라우저가 다듬은 주소, 다른 묶음 주소는 그대로', async () => {
const router = createRouter({ history: createMemoryHistory(), routes })
for (const path of ['/settings/info', '/settings/info/']) {
await router.push(path)
expect(router.currentRoute.value.name, path).toBe('settings-info-release-notes')
expect(router.currentRoute.value.fullPath, path).toBe('/settings/info/release-notes')
}
expect(router.resolve('/settings/account').name).toBe('not-found')
expect(router.resolve('/settings/mail').name).toBe('not-found')
})
it('업데이트 내역 메뉴는 문서 아이콘(file-text) — 목록이면 aria-current="page", 글을 보는 동안에는 고른 모양 그대로 aria-current="true"', async () => {
for (const [path, current] of [
['/settings/info/release-notes', 'page'],
['/settings/info/release-notes/2026-11-18', 'true'],
] as const) {
const { wrapper } = await mountAt(path)
const marked = wrapper.get('nav[aria-label="설정 메뉴"]').findAll('a[aria-current]')
expect(marked.map((link) => [link.text(), link.attributes('aria-current')]), path).toEqual([['업데이트 내역', current]])
expect(marked[0]?.classes()).toContain('is-active')
expect(marked[0]?.find('path').attributes('d')).toBe('M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z')
expect(marked[0]?.find('polyline').attributes('points')).toBe('14 2 14 8 20 8')
wrapper.unmount()
}
})
it('메뉴 항목을 누르면 그 화면으로 간다 — 글을 보다가 업데이트 내역을 누르면 목록', async () => {
const { wrapper, router } = await mountAt('/settings/info/release-notes/2026-11-18')
const item = wrapper
.get('nav[aria-label="설정 메뉴"]')
.findAll('a')
.find((link) => link.text() === '업데이트 내역')
await item?.trigger('click', { button: 0 })
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/settings/info/release-notes')
})
it('지금 화면의 항목만 현재로 표시한다', async () => {
const { wrapper } = await mountAt('/settings/mail/vacation')
const current = wrapper.get('nav[aria-label="설정 메뉴"]').findAll('a[aria-current="page"]')
+19 -9
View File
@@ -1,7 +1,10 @@
<script setup lang="ts">
// 설정 모듈 레이아웃 (디자인 원본 Settings · SettingsAccount · SettingsVacation) — 전역 셸 + 왼쪽 설정 메뉴(208 · #F8F9FB) + 하위 화면
// 메뉴는 묶음 둘: 계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 · 필터 · 알림 (디자인 README "4단계 › 설정 메뉴" · "5단계" · "6단계", 목록은 settings-menu.ts).
// 묶음 이름은 12px #667085 굵게, 항목은 40 · 아이콘 16. 지금 화면의 항목은 aria-current="page" (RouterLink).
// 메뉴는 묶음 셋: 계정 › 비밀번호 … / 메일 › 서명 · 부재중 자동 응답 · 필터 · 알림 … / 정보 › 업데이트 내역
// (디자인 README "4단계 › 설정 메뉴" · "5단계" · "6단계" · "업데이트 내역", 목록은 settings-menu.ts).
// 묶음 이름은 12px #667085 굵게, 항목은 40 · 아이콘 16. 지금 화면의 항목은 청록 바탕 + aria-current="page" 다 —
// 그 항목 아래에 묶은 화면(업데이트 내역의 글)을 보는 동안에도 고른 상태(청록 바탕)로 두되, 링크가 가리키는 곳(목록)과 지금 쪽(글)이 달라
// aria-current 는 "true" 다(RouterLink 의 isExactActive · isActive — 메일함 패널의 지금 메일함과 같은 결)
import AppIcon from '@/components/AppIcon.vue'
import AppShell from '@/components/shell/AppShell.vue'
import { SETTINGS_MENU } from '@/shared/constants/settings-menu'
@@ -36,15 +39,22 @@ function groupLabelId(groupId: string): string {
:key="item.routeName"
>
<RouterLink
v-slot="{ href, navigate, isActive, isExactActive }"
:to="{ name: item.routeName }"
class="settings-menu__link"
active-class="is-active"
custom
>
<AppIcon
:name="item.icon"
:size="16"
/>
<span>{{ item.label }}</span>
<a
:href="href"
:class="['settings-menu__link', { 'is-active': isActive }]"
:aria-current="isExactActive ? 'page' : isActive ? 'true' : undefined"
@click="navigate"
>
<AppIcon
:name="item.icon"
:size="16"
/>
<span>{{ item.label }}</span>
</a>
</RouterLink>
</li>
</ul>
+14
View File
@@ -89,4 +89,18 @@ describe('router 가드', () => {
await router.push('/login?redirect=/settings/account/two-factor')
expect(router.currentRoute.value.fullPath).toBe('/settings/account/two-factor')
})
// 업데이트 내역 (2026-11-10 — 디자인 README "업데이트 내역") — 목록 · 글 모두 탭 제목 "업데이트 내역"
it('업데이트 내역의 목록 · 글은 로그인해야 열리고, 탭 제목은 둘 다 "업데이트 내역 · ARA SYSTEM"', async () => {
installMockApi(() => ok(USER))
useAuthStore().markAuthenticated(USER)
await router.push('/settings/info/release-notes')
expect(router.currentRoute.value.name).toBe('settings-info-release-notes')
expect(document.title).toBe('업데이트 내역 · ARA SYSTEM')
await router.push('/settings/info/release-notes/2026-11-18')
expect(router.currentRoute.value.name).toBe('settings-info-release-note')
expect(document.title).toBe('업데이트 내역 · ARA SYSTEM')
})
})
+25 -1
View File
@@ -9,6 +9,7 @@ import {
MOBILE_APP_ROUTE,
MOBILE_APP_SETTINGS_TEXT,
} from '@/shared/constants/mobile-app'
import { RELEASE_NOTES_TEXT } from '@/shared/constants/release-notes'
import { SETTINGS_ROUTES } from '@/shared/constants/settings-menu'
import {
MAIL_SCHEDULED_MESSAGE_ROUTE,
@@ -112,7 +113,7 @@ export const routes: RouteRecordRaw[] = [
},
{
// 설정 — 왼쪽 메뉴(계정 › 비밀번호 · 2단계 인증 · 앱 비밀번호 · 메일 프로그램 연결 · 휴대폰 앱 · 로그인 기록 / 메일 › 서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 ·
// 차단한 주소 · 대용량 첨부 · 알림) + 하위 화면. /settings 는 첫 메뉴(계정 › 비밀번호)로 바꿔 끼운다 (앱 레일의 "설정")
// 차단한 주소 · 대용량 첨부 · 알림 / 정보 › 업데이트 내역) + 하위 화면. /settings 는 첫 메뉴(계정 › 비밀번호)로 바꿔 끼운다 (앱 레일의 "설정")
path: '/settings',
component: () => import('@/pages/settings/SettingsLayout.vue'),
meta: { requiresAuth: true, title: '설정' },
@@ -201,6 +202,29 @@ export const routes: RouteRecordRaw[] = [
component: () => import('@/pages/settings/MailNotifyPage.vue'),
meta: { title: '메일 알림' },
},
{
// 업데이트 내역 (2026-11-10 — 디자인 README "업데이트 내역", 계약 7절) — 목록 /settings/info/release-notes · 글 /settings/info/release-notes/<글 id>.
// 둘을 화면 없는 상위 라우트로 묶었다 — 글을 보는 동안에도 메뉴의 업데이트 내역이 고른 상태다(메뉴 링크가 목록 라우트 = 빈 하위 경로라
// RouterLink 가 같은 상위의 다른 하위(글)도 고른 것으로 본다). 탭 제목은 둘 다 "업데이트 내역"
path: 'info/release-notes',
meta: { title: RELEASE_NOTES_TEXT.title },
// 주소창에 …/release-notes/.. (%2E%2E 도)를 치면 브라우저가 /settings/info/ 로 다듬는다 — 아래 'info' 가 목록으로 바꿔 끼운다
children: [
{
path: '',
name: SETTINGS_ROUTES.releaseNotes,
component: () => import('@/pages/settings/InfoReleaseNotesPage.vue'),
},
{
path: ':id',
name: SETTINGS_ROUTES.releaseNote,
component: () => import('@/pages/settings/InfoReleaseNotePage.vue'),
},
],
},
// /settings/info — 정보 묶음의 화면은 업데이트 내역 하나라 목록으로 바꿔 끼운다(글 주소의 ".." 를 브라우저가 다듬은 주소이기도 하다).
// 다른 묶음의 주소(/settings/account · /settings/mail)는 그대로다
{ path: 'info', redirect: { name: SETTINGS_ROUTES.releaseNotes } },
],
},
{
@@ -0,0 +1,33 @@
// 설정 › 정보 › 업데이트 내역 — 화면 글의 원본 (디자인 원본 ReleaseNotes A · README "업데이트 내역")
// 글은 보드 그대로다. 글을 고치면 디자인 README 의 그 절도 함께 고친다. 글 내용(제목 · 묶음 · 줄)은 서버가 주는 글 그대로 그린다 — 여기 두지 않는다.
export const RELEASE_NOTES_TEXT = {
/** 설정 메뉴의 셋째 묶음 이름 */
menuGroup: '정보',
/** 메뉴 항목 · 목록 화면 제목 · 탭 제목(목록 · 글 둘 다) */
title: '업데이트 내역',
/** 목록 화면 제목 아래 한 줄 — 글 화면에는 두지 않는다 */
description: '웹메일과 휴대폰 앱에 새로 생기거나 바뀐 것을 알려 드립니다.',
/** 목록 머리 줄 (보이는 꼴만 — 화면 낭독은 줄 링크가 제목과 날짜를 함께 읽는다) */
columnTitle: '제목',
columnDate: '날짜',
/** 목록을 불러오는 동안 막대 자리의 이름 */
listLoading: '업데이트 내역 불러오는 중',
/** 글이 하나도 없을 때 */
empty: '아직 알려 드릴 업데이트가 없습니다.',
more: '더 보기',
/** 목록을 받지 못했을 때의 단추 — 목록은 빠져나갈 곳이 없어 없는 주소(404)여도 다시 받는다 */
retry: '다시 시도',
/** 글 화면 위 줄의 [← 목록으로] · 없는 글의 단추 */
backToList: '목록으로',
/** 글 끝의 [목록] */
list: '목록',
/** 글을 불러오는 동안 막대 자리의 이름 */
noteLoading: '글 불러오는 중',
/** 글 끝의 다른 글 줄 묶음 */
neighbors: '다른 글',
/** 바로 더 새 글 (서버의 next) — 위 줄 */
newer: '다음 글',
/** 바로 더 옛 글 (서버의 previous) — 아래 줄 */
older: '이전 글',
} as const
+11 -2
View File
@@ -1,12 +1,13 @@
// 설정 모듈의 왼쪽 메뉴 — 묶음(계정 · 메일) 아래 항목 (디자인 README "4단계 › 설정 메뉴" · "5단계 › 설정 › 메일 › 알림" · "6단계 › 설정 › 메일 › 필터" ·
// 설정 모듈의 왼쪽 메뉴 — 묶음(계정 · 메일 · 정보) 아래 항목 (디자인 README "4단계 › 설정 메뉴" · "5단계 › 설정 › 메일 › 알림" · "6단계 › 설정 › 메일 › 필터" ·
// "9단계 › 설정 · 보내기 · 읽기 · 차단한 주소" · "11단계 › 설정 › 메일 › 대용량 첨부" — 차단한 주소 아래 ·
// "12단계 — 계정 보안" — 계정 › 비밀번호 · 2단계 인증 · 앱 비밀번호 · 로그인 기록 · "13단계" — 앱 비밀번호 아래 메일 프로그램 연결 ·
// "휴대폰 앱 받기"(2026-11-08) — 메일 프로그램 연결 아래 휴대폰 앱)
// "휴대폰 앱 받기"(2026-11-08) — 메일 프로그램 연결 아래 휴대폰 앱 · "업데이트 내역"(2026-11-10) — 셋째 묶음 정보 › 업데이트 내역)
// 라우트(router/routes.ts) · 메뉴(SettingsLayout.vue) · 다른 화면의 링크(메일함 패널의 부재중 안내 · 알림을 막아 둔 오류 알림의 [설정 열기] ·
// 읽기 화면 ⋯ 「이런 메일로 필터 만들기」 · 계정 보안 화면끼리의 링크 · 휴대폰 앱의 아이폰 안내)가 이 이름을 함께 쓴다.
import type { IconName } from '@/shared/constants/icons'
import { MOBILE_APP_SETTINGS_TEXT } from '@/shared/constants/mobile-app'
import { RELEASE_NOTES_TEXT } from '@/shared/constants/release-notes'
export const SETTINGS_ROUTES = {
/** 설정의 첫 화면 — /settings 는 이 화면으로 바꿔 끼운다 (앱 레일의 "설정") */
@@ -29,6 +30,9 @@ export const SETTINGS_ROUTES = {
/** 대용량 첨부 — 보관 사용량 · 보내지 않은 파일 · 보낸 링크 (11단계) */
largeFiles: 'settings-mail-large-files',
notify: 'settings-mail-notify',
/** 업데이트 내역 — 목록 · 글 (2026-11-10). 글을 보는 동안에도 메뉴의 업데이트 내역이 고른 상태다(라우트를 목록 아래에 묶었다) */
releaseNotes: 'settings-info-release-notes',
releaseNote: 'settings-info-release-note',
} as const
export interface SettingsMenuItem {
@@ -70,4 +74,9 @@ export const SETTINGS_MENU: readonly SettingsMenuGroup[] = [
{ routeName: SETTINGS_ROUTES.notify, label: '알림', icon: 'bell' },
],
},
{
id: 'info',
label: RELEASE_NOTES_TEXT.menuGroup,
items: [{ routeName: SETTINGS_ROUTES.releaseNotes, label: RELEASE_NOTES_TEXT.title, icon: 'file-text' }],
},
]
@@ -0,0 +1,27 @@
// 업데이트 내역 API 공유 타입 — 서버와 맞춘 약속(2026-11-10 — 계약 7절 "업데이트 내역")과 같은 구조
// GET /api/release-notes?cursor=&limit= → { items, pageInfo } (최근 글부터) · GET /api/release-notes/{id} (없는 글 · 모양이 틀린 id 는 404 RES_001)
// 글은 서버가 다듬은 글 그대로다 — 화면은 마크다운 해석 · v-html 없이 글로만 그린다
/** 목록의 한 줄 · 글 화면의 다른 글 줄 */
export interface ReleaseNoteSummary {
/** 글 파일 이름 — "2026-11-18" · 같은 날 둘째부터 "2026-11-18-2" */
id: string
/** 달력 날짜 "2026-11-18" — 시각이 아니다(화면은 시간대를 바꾸지 않고 그대로 쓴다 — formatCalendarDate) */
date: string
title: string
}
/** 글의 묶음 — 머리(없으면 null) + 점 목록 */
export interface ReleaseNoteSection {
heading: string | null
items: string[]
}
/** 글 하나 */
export interface ReleaseNote extends ReleaseNoteSummary {
sections: ReleaseNoteSection[]
/** 바로 더 옛 글 (목록 차례의 아래) — 없으면 null */
previous: ReleaseNoteSummary | null
/** 바로 더 새 글 (목록 차례의 위) — 없으면 null */
next: ReleaseNoteSummary | null
}
+42
View File
@@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest'
import {
formatBlockedDay,
formatCalendarDate,
isCalendarDate,
formatConversationTime,
formatDraftSavedTime,
formatPrintDateTime,
@@ -237,3 +239,43 @@ describe('formatConversationTime (14단계 — 같은 대화 칸의 시각, 디
expect(formatConversationTime('나중에', now, SEOUL)).toBe('')
})
})
describe('formatCalendarDate (업데이트 내역 — 달력 날짜 글, 디자인 원본 ReleaseNotes)', () => {
it('"2026-11-18" → "2026년 11월 18일" — 달 · 날의 앞 0 은 쓰지 않는다', () => {
expect(formatCalendarDate('2026-11-18')).toBe('2026년 11월 18일')
expect(formatCalendarDate('2026-11-05')).toBe('2026년 11월 5일')
expect(formatCalendarDate('2027-01-01')).toBe('2027년 1월 1일')
})
it('시간대를 거치지 않는다 — 해가 바뀌는 날도 쓴 날 그대로 (Date 로 읽으면 UTC 자정이라 서쪽 시간대에서 하루 앞 · 지난해가 된다)', () => {
// 이 시험이 도는 시간대가 어디든(개발 PC 는 KST · CI 는 UTC — TZ=UTC · America/Los_Angeles 로도 돌려 보았다) 글이 같다
expect(formatCalendarDate('2026-01-01')).toBe('2026년 1월 1일')
expect(formatCalendarDate('2026-12-31')).toBe('2026년 12월 31일')
})
it('윤년의 2월 29일은 받고, 실제 없는 날 · 꼴이 틀린 글은 빈 글', () => {
expect(formatCalendarDate('2028-02-29')).toBe('2028년 2월 29일')
expect(formatCalendarDate('2000-02-29')).toBe('2000년 2월 29일')
expect(formatCalendarDate('2026-02-29')).toBe('')
expect(formatCalendarDate('2100-02-29')).toBe('')
expect(formatCalendarDate('2026-02-30')).toBe('')
expect(formatCalendarDate('2026-04-31')).toBe('')
expect(formatCalendarDate('2026-13-01')).toBe('')
expect(formatCalendarDate('2026-00-10')).toBe('')
expect(formatCalendarDate('2026-11-00')).toBe('')
expect(formatCalendarDate('2026-11-18T00:00:00Z')).toBe('')
expect(formatCalendarDate('2026-11-18-2')).toBe('')
expect(formatCalendarDate('2026-1-8')).toBe('')
expect(formatCalendarDate('')).toBe('')
})
})
describe('isCalendarDate (업데이트 내역 응답 읽기 — 실제 있는 달력 날짜)', () => {
it('"YYYY-MM-DD" 이고 실제 있는 날만 참', () => {
expect(isCalendarDate('2026-11-18')).toBe(true)
expect(isCalendarDate('2028-02-29')).toBe(true)
expect(isCalendarDate('2026-02-29')).toBe(false)
expect(isCalendarDate('2026-11-18T00:00:00Z')).toBe(false)
expect(isCalendarDate('')).toBe(false)
})
})
+39
View File
@@ -337,6 +337,45 @@ export function formatLongDate(value: string, timeZone?: string): string {
return new Intl.DateTimeFormat('ko-KR', { timeZone, year: 'numeric', month: 'long', day: 'numeric' }).format(date)
}
/** 달력 날짜 글 "YYYY-MM-DD" — 시각이 아니다 (업데이트 내역의 글 날짜 — 계약 7절) */
const CALENDAR_DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/
const THIRTY_DAY_MONTHS: ReadonlySet<number> = new Set([4, 6, 9, 11])
function isLeapYear(year: number): boolean {
return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0
}
function daysInMonth(year: number, month: number): number {
if (month === 2) return isLeapYear(year) ? 29 : 28
return THIRTY_DAY_MONTHS.has(month) ? 30 : 31
}
/** 달력 날짜 글을 연 · 월 · 일로 — 꼴이 틀렸거나 실제 없는 날(2026-02-30)이면 null */
function calendarDateParts(value: string): CalendarDay | null {
const match = CALENDAR_DATE_PATTERN.exec(value)
if (!match) return null
const year = Number(match[1])
const month = Number(match[2])
const day = Number(match[3])
if (month < 1 || month > 12 || day < 1 || day > daysInMonth(year, month)) return null
return { year, month, day }
}
/** 실제 있는 날의 달력 날짜 글 "YYYY-MM-DD" 인지 (업데이트 내역 응답 읽기 — 앱의 CalendarDate.tryParse 와 같다) */
export function isCalendarDate(value: string): boolean {
return calendarDateParts(value) !== null
}
/**
* 달력 날짜 글을 연도까지 쓴 날로 — "2026-11-18" → "2026년 11월 18일" (디자인 원본 ReleaseNotes).
* 시간대를 거치지 않는다 — new Date('2026-11-18') 은 UTC 자정이라 서쪽 시간대(UTC−)에서는 하루 앞 날짜가 된다.
* 꼴이 틀렸거나 실제 없는 날(2026-02-30)이면 ''
*/
export function formatCalendarDate(value: string): string {
const parts = calendarDateParts(value)
return parts ? `${parts.year}년 ${parts.month}월 ${parts.day}일` : ''
}
/** 문의 코드로 보여 줄 traceId 앞자리 수 (디자인 원본 States: "문의 코드 3f1c0a2e") */
export const TRACE_CODE_LENGTH = 8
@@ -0,0 +1,149 @@
import { describe, expect, it } from 'vitest'
import {
RELEASE_NOTE_ID_MAX_LENGTH,
isReleaseNoteId,
releaseNoteNeighbors,
releaseNoteOf,
releaseNotePageOf,
releaseNoteSummaryOf,
} from './release-notes'
// 업데이트 내역 — 글 id 꼴 · 응답 읽기 · 다른 글 줄 (계약 7절 "업데이트 내역", 디자인 원본 ReleaseNotes)
const TOP = { id: '2026-11-18', date: '2026-11-18', title: '메일 검색이 빨라지고 휴대폰 앱이 나왔습니다' }
const MID = { id: '2026-11-05', date: '2026-11-05', title: '화면의 안내 글을 다듬었습니다' }
const MENU = { id: '2026-11-04', date: '2026-11-04', title: '왼쪽 메뉴를 정리했습니다' }
describe('isReleaseNoteId (주소의 글 id 꼴)', () => {
it('파일 이름 꼴만 받는다 — "2026-11-18" · 같은 날 둘째부터 "2026-11-18-2"', () => {
expect(isReleaseNoteId('2026-11-18')).toBe(true)
expect(isReleaseNoteId('2026-11-18-2')).toBe(true)
expect(isReleaseNoteId('2026-11-18-12')).toBe(true)
})
it('경로 조각 "." · ".." · 다른 글자 · 빈 글 · 32자 넘음은 받지 않는다 — 묻지 않고 없는 글', () => {
for (const value of ['.', '..', '', '2026-11-18.md', '2026-11-18-', '2026-11-18-x', 'latest', '2026/11/18', ' 2026-11-18', '2026-11-18 ']) {
expect(isReleaseNoteId(value), value).toBe(false)
}
const long = `2026-11-18-${'1'.repeat(RELEASE_NOTE_ID_MAX_LENGTH - 10)}`
expect(long.length).toBe(RELEASE_NOTE_ID_MAX_LENGTH + 1)
expect(isReleaseNoteId(long)).toBe(false)
expect(isReleaseNoteId(long.slice(0, -1))).toBe(true)
})
})
describe('releaseNotePageOf (목록 응답)', () => {
it('{ items, pageInfo } 를 그대로 읽는다', () => {
const page = releaseNotePageOf({ items: [TOP, MID], pageInfo: { hasNext: true, nextCursor: 'c2', total: 24 } })
expect(page).toEqual({ items: [TOP, MID], pageInfo: { hasNext: true, nextCursor: 'c2', total: 24 } })
})
it('모르는 칸은 버린다 · 커서가 없거나 빈 글이면 다음 쪽이 없는 것으로 · 수가 없으면 모름(null)', () => {
expect(releaseNotePageOf({ items: [{ ...TOP, extra: 1 }], pageInfo: { hasNext: false, nextCursor: null, total: 1 } })).toEqual({
items: [TOP],
pageInfo: { hasNext: false, nextCursor: null, total: 1 },
})
expect(releaseNotePageOf({ items: [], pageInfo: { hasNext: true, nextCursor: null, total: null } })?.pageInfo).toEqual({
hasNext: false,
nextCursor: null,
total: null,
})
expect(releaseNotePageOf({ items: [], pageInfo: { hasNext: true, nextCursor: '' } })?.pageInfo).toEqual({
hasNext: false,
nextCursor: null,
total: null,
})
})
it('모양이 틀리면(줄 하나라도) null — 받지 못한 것으로 본다(앱과 같다)', () => {
const pageInfo = { hasNext: false, nextCursor: null, total: 2 }
for (const reply of [
null,
'x',
{ items: [TOP] },
{ items: 'x', pageInfo },
{ items: [TOP], pageInfo: null },
{ items: [TOP], pageInfo: { hasNext: 'yes', nextCursor: null, total: 1 } },
{ items: [TOP], pageInfo: { hasNext: true, nextCursor: 3, total: 1 } },
{ items: [TOP], pageInfo: { hasNext: false, nextCursor: null, total: '1' } },
{ items: [TOP, null], pageInfo },
{ items: [TOP, { id: 3, date: '2026-11-05', title: '숫자 id' }], pageInfo },
{ items: [TOP, { id: '', date: '2026-11-05', title: '빈 id' }], pageInfo },
{ items: [TOP, { id: '2026-02-30', date: '2026-02-30', title: '없는 날' }], pageInfo },
{ items: [TOP, { id: 'a', date: 'b' }], pageInfo },
]) {
expect(releaseNotePageOf(reply), JSON.stringify(reply)).toBeNull()
}
})
})
describe('releaseNoteOf (글 응답)', () => {
const REPLY = {
...MID,
sections: [
{ heading: null, items: ['단추와 안내 글을 더 알기 쉬운 말로 고쳤습니다.'] },
{ heading: '주소록 · 설정', items: ['내 주소록을 파일로 내보낼 수 있습니다. [주소록] → [내보내기]'] },
],
previous: MENU,
next: TOP,
}
it('글 · 묶음 · 이전 글 · 다음 글을 그대로 읽는다', () => {
expect(releaseNoteOf(REPLY)).toEqual(REPLY)
})
it('빈 머리는 머리 없음 · previous · next 칸이 없으면 없는 것(null)으로 본다', () => {
expect(
releaseNoteOf({ ...TOP, sections: [{ heading: '', items: ['머리 없는 줄'] }, { items: ['머리 칸이 없는 줄'] }] }),
).toEqual({
...TOP,
sections: [
{ heading: null, items: ['머리 없는 줄'] },
{ heading: null, items: ['머리 칸이 없는 줄'] },
],
previous: null,
next: null,
})
})
it('모양이 틀리면(묶음 · 줄 · 다른 글 하나라도) null — 받지 못한 것으로 본다(앱과 같다)', () => {
for (const reply of [
undefined,
{ ...REPLY, title: null },
{ ...REPLY, date: '2026-11-31' },
{ items: [], pageInfo: {} },
{ ...REPLY, sections: undefined },
{ ...REPLY, sections: [...REPLY.sections, 'x'] },
{ ...REPLY, sections: [{ heading: 3, items: ['줄'] }] },
{ ...REPLY, sections: [{ heading: null, items: 'x' }] },
{ ...REPLY, sections: [{ heading: null, items: ['줄', 3] }] },
{ ...REPLY, previous: { id: '2026-11-05' } },
{ ...REPLY, next: 'x' },
]) {
expect(releaseNoteOf(reply), JSON.stringify(reply)).toBeNull()
}
})
it('releaseNoteSummaryOf — 다른 칸은 버리고 id · 날짜 · 제목만, 날짜는 실제 있는 달력 날짜여야 한다', () => {
expect(releaseNoteSummaryOf({ ...MID, sections: [] })).toEqual(MID)
expect(releaseNoteSummaryOf([])).toBeNull()
expect(releaseNoteSummaryOf({ ...MID, date: '2026-11-05T00:00:00Z' })).toBeNull()
})
})
describe('releaseNoteNeighbors (글 끝의 다른 글 줄)', () => {
it('다음 글(더 새 글)이 위 · 이전 글(더 옛 글)이 아래 — 목록과 같은 차례', () => {
expect(releaseNoteNeighbors({ next: TOP, previous: MENU })).toEqual([
{ kind: 'newer', note: TOP },
{ kind: 'older', note: MENU },
])
})
it('없는 쪽은 줄이 없다 — 맨 위 글은 이전 글만, 맨 아래 글은 다음 글만, 글이 하나뿐이면 없음', () => {
expect(releaseNoteNeighbors({ next: null, previous: MID })).toEqual([{ kind: 'older', note: MID }])
expect(releaseNoteNeighbors({ next: MID, previous: null })).toEqual([{ kind: 'newer', note: MID }])
expect(releaseNoteNeighbors({ next: null, previous: null })).toEqual([])
})
})
+115
View File
@@ -0,0 +1,115 @@
// 업데이트 내역 — 응답 읽기 · 글 주소 · 다른 글 줄 (순수 함수, 계약 7절 "업데이트 내역")
// 응답은 계약의 모양 그대로일 때만 읽는다 — 줄 · 묶음 · 다른 글 하나라도 모양이 틀리면 null(받지 못한 것 — SYS_001)로 본다.
// 앱(flutter features/settings/data/models/release_note.dart 의 tryParse)과 같은 규칙이다 — 한쪽을 고치면 둘 다
import type { PageInfo, PaginatedResult } from '@/shared/types/pagination'
import type { ReleaseNote, ReleaseNoteSection, ReleaseNoteSummary } from '@/shared/types/release-notes'
import { isCalendarDate } from '@/shared/utils/format'
/** 글 id 의 길이 한도 — 서버의 경로 검사와 같은 값 */
export const RELEASE_NOTE_ID_MAX_LENGTH = 32
/** 글 id — 파일 이름 "2026-11-18" · 같은 날 둘째부터 "2026-11-18-2" (서버가 받는 꼴을 모두 받는 넓이) */
const RELEASE_NOTE_ID_PATTERN = /^\d{4}-\d{2}-\d{2}(?:-\d+)?$/
/**
* 주소의 글 id 가 글 id 꼴인지 — 아니면 묻지 않고 "없는 글"로 본다.
* 경로 조각 "." · ".." 은 브라우저가 주소를 다듬으며 다른 경로(목록 · /api)로 바꿔 보내므로 꼴부터 거른다
*/
export function isReleaseNoteId(value: string): boolean {
return value.length <= RELEASE_NOTE_ID_MAX_LENGTH && RELEASE_NOTE_ID_PATTERN.test(value)
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
/** 값이 없음(null · 칸 없음)인지 */
function isAbsent(value: unknown): value is null | undefined {
return value === null || value === undefined
}
/** 목록의 한 줄 · 다른 글 — id(빈 글 아님) · 날짜(실제 있는 달력 날짜 "YYYY-MM-DD") · 제목이 모양에 맞지 않으면 null */
export function releaseNoteSummaryOf(value: unknown): ReleaseNoteSummary | null {
if (!isRecord(value)) return null
const { id, date, title } = value
if (typeof id !== 'string' || id === '' || typeof title !== 'string') return null
if (typeof date !== 'string' || !isCalendarDate(date)) return null
return { id, date, title }
}
/** pageInfo — hasNext 가 참 · 거짓이 아니거나 커서 · 수의 모양이 틀리면 null. 다음 쪽은 커서가 있어야 받을 수 있다 */
function pageInfoOf(value: unknown): PageInfo | null {
if (!isRecord(value)) return null
const { hasNext, nextCursor, total } = value
if (typeof hasNext !== 'boolean') return null
if (!isAbsent(nextCursor) && typeof nextCursor !== 'string') return null
if (!isAbsent(total) && typeof total !== 'number') return null
const cursor = typeof nextCursor === 'string' && nextCursor !== '' ? nextCursor : null
return { hasNext: hasNext && cursor !== null, nextCursor: cursor, total: typeof total === 'number' ? total : null }
}
/** 목록 응답 { items, pageInfo } — 모양이 틀리면(줄 하나라도) null */
export function releaseNotePageOf(reply: unknown): PaginatedResult<ReleaseNoteSummary> | null {
if (!isRecord(reply) || !Array.isArray(reply.items)) return null
const pageInfo = pageInfoOf(reply.pageInfo)
if (!pageInfo) return null
const items: ReleaseNoteSummary[] = []
for (const item of reply.items as unknown[]) {
const summary = releaseNoteSummaryOf(item)
if (!summary) return null
items.push(summary)
}
return { items, pageInfo }
}
/** 묶음 — 머리는 글 또는 없음(빈 머리는 없음으로), 줄은 글의 배열. 모양이 틀리면 null */
function sectionOf(value: unknown): ReleaseNoteSection | null {
if (!isRecord(value)) return null
const { heading, items } = value
if (!isAbsent(heading) && typeof heading !== 'string') return null
if (!Array.isArray(items)) return null
const lines: string[] = []
for (const item of items as unknown[]) {
if (typeof item !== 'string') return null
lines.push(item)
}
return { heading: typeof heading === 'string' && heading !== '' ? heading : null, items: lines }
}
/** 다른 글 칸 — 없음(null · 칸 없음)은 null, 모양이 틀리면 undefined */
function neighborOf(value: unknown): ReleaseNoteSummary | null | undefined {
if (isAbsent(value)) return null
return releaseNoteSummaryOf(value) ?? undefined
}
/** 글 응답 — 모양이 틀리면(묶음 · 다른 글 하나라도) null. previous · next 칸이 없으면 없는 것(null)으로 본다 */
export function releaseNoteOf(reply: unknown): ReleaseNote | null {
const summary = releaseNoteSummaryOf(reply)
if (!summary || !isRecord(reply) || !Array.isArray(reply.sections)) return null
const sections: ReleaseNoteSection[] = []
for (const raw of reply.sections as unknown[]) {
const section = sectionOf(raw)
if (!section) return null
sections.push(section)
}
const previous = neighborOf(reply.previous)
const next = neighborOf(reply.next)
if (previous === undefined || next === undefined) return null
return { ...summary, sections, previous, next }
}
/** 다른 글 줄의 종류 — newer: 바로 더 새 글(다음 글) · older: 바로 더 옛 글(이전 글) */
export type ReleaseNoteNeighborKind = 'newer' | 'older'
export interface ReleaseNoteNeighbor {
kind: ReleaseNoteNeighborKind
note: ReleaseNoteSummary
}
/** 글 끝의 다른 글 줄 — 다음 글(더 새 글)이 위 · 이전 글(더 옛 글)이 아래, 목록과 같은 차례. 없는 쪽은 줄이 없다 */
export function releaseNoteNeighbors(note: Pick<ReleaseNote, 'previous' | 'next'>): ReleaseNoteNeighbor[] {
const rows: ReleaseNoteNeighbor[] = []
if (note.next) rows.push({ kind: 'newer', note: note.next })
if (note.previous) rows.push({ kind: 'older', note: note.previous })
return rows
}
@@ -0,0 +1,285 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
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 MockResult,
} from '@/test-utils/mock-api'
import { useReleaseNotesStore } from './release-notes.store'
// 업데이트 내역 스토어 — 목록(20개씩 · [더 보기]) · 글 (계약 7절 "업데이트 내역")
// 처음 받기 · 글은 알림을 끄고 화면이 칸 자리에 그린다. [더 보기]의 실패는 인터셉터가 알리고 받은 줄은 그대로.
// 세션이 바뀐 뒤 늦게 온 응답은 쓰지 않는다
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
function summary(day: number, title = `${day}일의 업데이트`): ReleaseNoteSummary {
const date = `2026-11-${String(day).padStart(2, '0')}`
return { id: date, date, title }
}
const FIRST_PAGE = Array.from({ length: 20 }, (_, index) => summary(30 - index))
const SECOND_PAGE = [summary(10), summary(9)]
const NOTE: ReleaseNote = {
...summary(18, '메일 검색이 빨라지고 휴대폰 앱이 나왔습니다'),
sections: [{ heading: '휴대폰 앱', items: ['안드로이드 휴대폰 앱이 나왔습니다. 웹메일의 [설정] → [휴대폰 앱]에서 받을 수 있습니다.'] }],
previous: summary(5),
next: null,
}
/** 응답을 손으로 보내는 요청 — 늦게 온 응답을 흉내 낸다 */
function deferredReply() {
let resolve: (result: MockResult) => void = () => {}
const promise = new Promise<MockResult>((settle) => {
resolve = settle
})
return { promise, resolve }
}
describe('release-notes.store', () => {
/** 같은 코드의 알림은 3초 안에 다시 띄우지 않는다(useApi) — 시험마다 시계를 1분씩 넘겨 앞 시험의 알림에 묻히지 않게 */
let clock = Date.parse('2026-11-10T09:00:00Z')
beforeEach(() => {
vi.useFakeTimers({ toFake: ['Date'] })
clock += 60_000
vi.setSystemTime(clock)
setActivePinia(createPinia())
useAuthStore().startSession(USER)
})
afterEach(() => {
useToastStore().clear()
restoreApiAdapter()
vi.useRealTimers()
})
describe('목록', () => {
it('첫 쪽 20개를 알림을 끈 채 받는다 — 다음 쪽이 있으면 hasMore', async () => {
const requests = installMockApi(() =>
ok({ items: FIRST_PAGE, pageInfo: { hasNext: true, nextCursor: 'c20', total: 22 } }),
)
const store = useReleaseNotesStore()
const loading = store.loadList()
expect(store.listStatus).toBe('loading')
await loading
expect(requests.map((request) => `${request.method} ${request.url}`)).toEqual(['GET /release-notes'])
expect(requests[0]?.params).toEqual({ limit: 20 })
expect(requests[0]?.config.silent).toBe(true)
expect(store.listStatus).toBe('ready')
expect(store.notes).toEqual(FIRST_PAGE)
expect(store.hasMore).toBe(true)
})
it('받지 못하면 error — 사전 문구 · 문의 코드를 상태에 담고 알리지 않는다', async () => {
installMockApi(() => fail(503, 'SYS_001'))
const store = useReleaseNotesStore()
await store.loadList()
expect(store.listStatus).toBe('error')
expect(store.listError?.code).toBe('SYS_001')
expect(store.listError?.message).toBe(ERROR_CODE_MESSAGES.SYS_001)
expect(store.listError?.traceId).toBe('3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d')
expect(store.notes).toEqual([])
expect(useToastStore().toasts).toEqual([])
})
it('[더 보기] — 받은 쪽 다음부터 이어 붙이고, 새로 붙은 첫 줄의 자리를 돌려준다(이미 받은 글은 다시 넣지 않는다)', async () => {
const requests = installMockApi((request) =>
request.params.cursor === 'c20'
? ok({ items: [FIRST_PAGE[19], ...SECOND_PAGE], pageInfo: { hasNext: false, nextCursor: null, total: 22 } })
: ok({ items: FIRST_PAGE, pageInfo: { hasNext: true, nextCursor: 'c20', total: 22 } }),
)
const store = useReleaseNotesStore()
await store.loadList()
const more = store.loadMore()
expect(store.loadingMore).toBe(true)
expect(await store.loadMore()).toBeNull()
expect(await more).toBe(20)
expect(requests.map((request) => request.params)).toEqual([{ limit: 20 }, { cursor: 'c20', limit: 20 }])
expect(requests[1]?.config.silent).toBeUndefined()
expect(store.notes.map((note) => note.id)).toEqual([...FIRST_PAGE, ...SECOND_PAGE].map((note) => note.id))
expect(store.loadingMore).toBe(false)
expect(store.hasMore).toBe(false)
})
it('[더 보기]를 받지 못하면 알림(인터셉터)으로 알리고 받은 줄 · 다음 쪽은 그대로', async () => {
installMockApi((request) =>
request.params.cursor === 'c20'
? fail(503, 'SYS_001')
: ok({ items: FIRST_PAGE, pageInfo: { hasNext: true, nextCursor: 'c20', total: 22 } }),
)
const store = useReleaseNotesStore()
await store.loadList()
expect(await store.loadMore()).toBeNull()
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([ERROR_CODE_MESSAGES.SYS_001])
expect(store.notes).toEqual(FIRST_PAGE)
expect(store.hasMore).toBe(true)
expect(store.loadingMore).toBe(false)
})
it('처음 목록 응답의 모양이 틀리면 받지 못함(SYS_001) — 알림 없이 화면이 그린다', async () => {
installMockApi(() => ok({ items: [...FIRST_PAGE, { id: 3 }], pageInfo: { hasNext: false, nextCursor: null, total: 21 } }))
const store = useReleaseNotesStore()
await store.loadList()
expect(store.listStatus).toBe('error')
expect(store.listError).toEqual({ code: 'SYS_001', message: ERROR_CODE_MESSAGES.SYS_001 })
expect(store.notes).toEqual([])
expect(useToastStore().toasts).toEqual([])
})
it('[더 보기] 응답의 모양이 틀리면 SYS_001 로 알리고 받은 줄 · [더 보기]는 그대로 (앱과 같다)', async () => {
installMockApi((request) =>
request.params.cursor === 'c20'
? ok({ items: SECOND_PAGE })
: ok({ items: FIRST_PAGE, pageInfo: { hasNext: true, nextCursor: 'c20', total: 22 } }),
)
const store = useReleaseNotesStore()
await store.loadList()
expect(await store.loadMore()).toBeNull()
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([ERROR_CODE_MESSAGES.SYS_001])
expect(store.notes).toEqual(FIRST_PAGE)
expect(store.hasMore).toBe(true)
expect(store.loadingMore).toBe(false)
})
it('세션이 바뀐 뒤 늦게 온 목록은 쓰지 않고, 받아 둔 값도 지운다', async () => {
const reply = deferredReply()
installMockApi(() => reply.promise)
const store = useReleaseNotesStore()
const loading = store.loadList()
useAuthStore().clearAuth()
useAuthStore().startSession({ email: 'doyun.lee@arasystem.it.kr', name: '이도윤' })
await flushPromises()
reply.resolve(ok({ items: FIRST_PAGE, pageInfo: { hasNext: true, nextCursor: 'c20', total: 22 } }))
await loading
expect(store.listStatus).toBe('idle')
expect(store.notes).toEqual([])
expect(store.pageInfo).toBeNull()
})
it('세션이 바뀐 뒤 늦게 온 [더 보기]도 붙이지 않는다', async () => {
const reply = deferredReply()
installMockApi((request) =>
request.params.cursor === 'c20'
? reply.promise
: ok({ items: FIRST_PAGE, pageInfo: { hasNext: true, nextCursor: 'c20', total: 22 } }),
)
const store = useReleaseNotesStore()
await store.loadList()
const more = store.loadMore()
useAuthStore().clearAuth()
await flushPromises()
reply.resolve(ok({ items: SECOND_PAGE, pageInfo: { hasNext: false, nextCursor: null, total: 22 } }))
expect(await more).toBeNull()
expect(store.notes).toEqual([])
expect(store.loadingMore).toBe(false)
})
})
describe('글', () => {
it('글 하나를 알림을 끈 채 받는다 — 경로의 id 는 인코딩한다', async () => {
const requests = installMockApi(() => ok(NOTE))
const store = useReleaseNotesStore()
const loading = store.loadNote('2026-11-18')
expect(store.noteStatus).toBe('loading')
await loading
expect(requests.map((request) => `${request.method} ${request.url}`)).toEqual(['GET /release-notes/2026-11-18'])
expect(requests[0]?.config.silent).toBe(true)
expect(store.noteStatus).toBe('ready')
expect(store.note).toEqual(NOTE)
})
it('없는 글(404 RES_001)은 missing · 그 밖의 실패는 error — 둘 다 알리지 않는다', async () => {
installMockApi((request) => (request.url.endsWith('2026-11-01') ? fail(404, 'RES_001') : fail(502, 'SYS_001')))
const store = useReleaseNotesStore()
await store.loadNote('2026-11-01')
expect(store.noteStatus).toBe('missing')
expect(store.noteError?.code).toBe('RES_001')
expect(store.note).toBeNull()
await store.loadNote('2026-11-02')
expect(store.noteStatus).toBe('error')
expect(store.noteError?.code).toBe('SYS_001')
expect(useToastStore().toasts).toEqual([])
})
it('글 id 꼴이 아닌 주소(".." 등)는 묻지 않고 없는 글 — 사전 문구 그대로', async () => {
const requests = installMockApi(() => ok(NOTE))
const store = useReleaseNotesStore()
await store.loadNote('..')
expect(requests).toEqual([])
expect(store.noteStatus).toBe('missing')
expect(store.noteError).toEqual({ code: 'RES_001', message: ERROR_CODE_MESSAGES.RES_001 })
})
it('응답의 모양이 틀리면(제목이 없다) 받지 못한 것으로 — SYS_001 사전 문구, 알림 없음', async () => {
installMockApi(() => ok({ id: '2026-11-18', date: '2026-11-18' }))
const store = useReleaseNotesStore()
await store.loadNote('2026-11-18')
expect(store.noteStatus).toBe('error')
expect(store.noteError).toEqual({ code: 'SYS_001', message: ERROR_CODE_MESSAGES.SYS_001 })
expect(useToastStore().toasts).toEqual([])
})
it('다른 글을 받기 시작하면 앞 글의 늦은 응답은 쓰지 않는다', async () => {
const first = deferredReply()
installMockApi((request) => (request.url.endsWith('2026-11-18') ? first.promise : ok({ ...NOTE, ...summary(5) })))
const store = useReleaseNotesStore()
const stale = store.loadNote('2026-11-18')
await store.loadNote('2026-11-05')
first.resolve(ok(NOTE))
await stale
expect(store.noteStatus).toBe('ready')
expect(store.note?.id).toBe('2026-11-05')
})
it('세션이 바뀐 뒤 늦게 온 글은 쓰지 않는다', async () => {
const reply = deferredReply()
installMockApi(() => reply.promise)
const store = useReleaseNotesStore()
const loading = store.loadNote('2026-11-18')
useAuthStore().expireSession()
await flushPromises()
reply.resolve(ok(NOTE))
await loading
expect(store.noteStatus).toBe('idle')
expect(store.note).toBeNull()
})
})
})
+159
View File
@@ -0,0 +1,159 @@
import { computed, ref, shallowRef, watch } from 'vue'
import { defineStore } from 'pinia'
import { getErrorMessage, toApiError, type ApiError } from '@/composables/useApi'
import { useReleaseNotes } from '@/composables/useReleaseNotes'
import { DEFAULT_PAGE_SIZE, type PageInfo } from '@/shared/types/pagination'
import type { ReleaseNote, ReleaseNoteSummary } from '@/shared/types/release-notes'
import { isReleaseNoteId } from '@/shared/utils/release-notes'
import { useAuthStore } from '@/stores/auth.store'
/** 목록 — idle(받기 전) · loading · ready · error(받지 못함 — 화면이 사전 문구와 [다시 시도]) */
export type ReleaseNoteListStatus = 'idle' | 'loading' | 'ready' | 'error'
/** 글 — idle · loading · ready · missing(없는 글 — 404 RES_001 · 글 id 꼴이 아닌 주소) · error(그 밖의 실패) */
export type ReleaseNoteStatus = 'idle' | 'loading' | 'ready' | 'missing' | 'error'
// 업데이트 내역 스토어 — 설정 › 정보 › 업데이트 내역의 목록과 글 (계약 7절 · 디자인 README "업데이트 내역")
// - 목록 · 글 화면은 들어올 때마다 새로 받는다(운영자가 배포하며 글을 더하거나 고친다). 받는 동안은 막대 자리만 — 지난번 값을 잠깐 그렸다 바꾸지 않는다
// - 처음 받기 · 글은 알림을 끄고(silent) 화면이 칸 자리에 그린다(받지 못함 · 없는 글). [더 보기]의 실패는 알리고(인터셉터 —
// 응답의 모양이 틀린 것은 useReleaseNotes 가 SYS_001 로) 받은 줄 · [더 보기]는 그대로 둔다
// - 늦게 온 응답은 쓰지 않는다 — 다시 받기(seq)가 시작됐거나 세션 세대(authStore.generation — 로그아웃 · 만료 · 다른 계정 로그인)가 바뀌었으면 버린다.
// 세션이 바뀌면 받아 둔 값도 지운다
export const useReleaseNotesStore = defineStore('release-notes', () => {
const api = useReleaseNotes()
const authStore = useAuthStore()
// ── 목록 ─────────────
const notes = ref<ReleaseNoteSummary[]>([])
const pageInfo = shallowRef<PageInfo | null>(null)
const listStatus = ref<ReleaseNoteListStatus>('idle')
const listError = ref<ApiError | null>(null)
/** [더 보기]로 다음 쪽을 받는 중 — 그 단추만 바쁨 */
const loadingMore = ref<boolean>(false)
let listSeq = 0
/** 다음 쪽이 있다 — [더 보기]를 둔다 */
const hasMore = computed<boolean>(
() => listStatus.value === 'ready' && pageInfo.value?.hasNext === true && pageInfo.value.nextCursor !== null,
)
/** 첫 쪽부터 새로 받는다 — 결과는 상태로만 남긴다(던지지 않는다) */
async function loadList(): Promise<void> {
listSeq += 1
const current = listSeq
const session = authStore.generation
const stale = () => current !== listSeq || session !== authStore.generation
notes.value = []
pageInfo.value = null
listStatus.value = 'loading'
listError.value = null
loadingMore.value = false
try {
const page = await api.listReleaseNotes({ limit: DEFAULT_PAGE_SIZE }, { silent: true })
if (stale()) return
notes.value = page.items
pageInfo.value = page.pageInfo
listStatus.value = 'ready'
} catch (caught) {
if (stale()) return
listStatus.value = 'error'
listError.value = toApiError(caught)
}
}
/**
* [더 보기] — 받은 쪽 다음부터 이어 받는다(이미 받은 글은 다시 넣지 않는다).
* @returns 새로 붙은 첫 줄의 자리 — 붙은 줄이 없거나 · 받지 못했거나 · 늦게 온 응답이면 null. 실패는 이미 알렸다(받은 줄 · 다음 쪽 커서는 그대로)
*/
async function loadMore(): Promise<number | null> {
const cursor = pageInfo.value?.nextCursor
if (loadingMore.value || !hasMore.value || !cursor) return null
const current = listSeq
const session = authStore.generation
const stale = () => current !== listSeq || session !== authStore.generation
loadingMore.value = true
try {
const page = await api.listReleaseNotes({ cursor, limit: DEFAULT_PAGE_SIZE })
if (stale()) return null
const known = new Set(notes.value.map((note) => note.id))
const added = page.items.filter((note) => !known.has(note.id))
const firstAdded = added.length > 0 ? notes.value.length : null
notes.value = [...notes.value, ...added]
pageInfo.value = page.pageInfo
return firstAdded
} catch {
// 인터셉터 · useReleaseNotes(모양이 틀린 응답)가 알렸다 — 받은 줄은 그대로
return null
} finally {
if (current === listSeq) loadingMore.value = false
}
}
// ── 글 ─────────────
const note = shallowRef<ReleaseNote | null>(null)
const noteStatus = ref<ReleaseNoteStatus>('idle')
const noteError = ref<ApiError | null>(null)
let noteSeq = 0
/** 글 하나를 새로 받는다 — 글 id 꼴이 아닌 주소는 묻지 않고 없는 글로 둔다. 결과는 상태로만 남긴다 */
async function loadNote(id: string): Promise<void> {
noteSeq += 1
const current = noteSeq
const session = authStore.generation
const stale = () => current !== noteSeq || session !== authStore.generation
note.value = null
noteError.value = null
if (!isReleaseNoteId(id)) {
noteStatus.value = 'missing'
noteError.value = { code: 'RES_001', message: getErrorMessage('RES_001') }
return
}
noteStatus.value = 'loading'
try {
const loaded = await api.getReleaseNote(id, { silent: true })
if (stale()) return
note.value = loaded
noteStatus.value = 'ready'
} catch (caught) {
if (stale()) return
const failure = toApiError(caught)
noteError.value = failure
noteStatus.value = failure.code === 'RES_001' ? 'missing' : 'error'
}
}
function reset() {
listSeq += 1
noteSeq += 1
notes.value = []
pageInfo.value = null
listStatus.value = 'idle'
listError.value = null
loadingMore.value = false
note.value = null
noteStatus.value = 'idle'
noteError.value = null
}
// 로그아웃 · 세션 만료 · 다른 계정 로그인 — 앞 세션의 응답 · 값이 남지 않게
watch(
() => authStore.generation,
() => reset(),
)
return {
notes,
pageInfo,
listStatus,
listError,
loadingMore,
hasMore,
loadList,
loadMore,
note,
noteStatus,
noteError,
loadNote,
reset,
}
})
+9
View File
@@ -129,6 +129,15 @@ export function createTestRouter(): Router {
component: Empty,
meta: { requiresAuth: true },
},
// 업데이트 내역 — 목록 · 글을 화면 없는 상위로 묶는다 (router/routes.ts 와 같은 이름 · 모양 — 글을 보는 동안에도 메뉴가 고른 상태)
{
path: '/settings/info/release-notes',
meta: { requiresAuth: true },
children: [
{ path: '', name: 'settings-info-release-notes', component: Empty },
{ path: ':id', name: 'settings-info-release-note', component: Empty },
],
},
// 주소록 (8단계 — router/routes.ts 와 같은 이름 · 모양)
{ path: '/contacts', name: 'contacts', component: Empty, meta: { requiresAuth: true } },
{ path: '/contacts/all/:contactId', name: 'contacts-contact', component: Empty, meta: { requiresAuth: true } },