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