feat(frontend): 설정 › 휴대폰 앱과 휴대폰 받기 화면 /app 을 더한다

확정 보드 SettingsMobileApp 대로 PC 에서는 QR 로 휴대폰에 넘기고, 셸 밖 /app(로그인 필요)에서 휴대폰으로 받는다.
내려받기 전에 세션을 확인하고, 올린 판이 없으면(404 RES_001) 알림 없이 안내와 문의 줄을 보인다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-08 23:29:04 +09:00
parent f2ba81513e
commit 433ef12cc3
24 changed files with 2141 additions and 10 deletions
+10
View File
@@ -85,6 +85,16 @@ describe('LoginPage', () => {
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m1')
})
it('휴대폰 받기 화면(QR 코드 · 앱의 [새 버전 받기])에서 왔으면 로그인한 뒤 /app 으로 돌아간다', async () => {
installMockApi(() => ok(USER))
const { wrapper, router } = await mountLogin('/login?redirect=/app')
await submit(wrapper, 'secret')
expect(router.currentRoute.value.name).toBe('mobile-app')
expect(router.currentRoute.value.fullPath).toBe('/app')
})
it('redirect 가 앱 밖 주소면 무시하고 메일로 간다', async () => {
installMockApi(() => ok(USER))
const { wrapper, router } = await mountLogin('/login?redirect=//evil.example')
@@ -0,0 +1,202 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import type { AppRelease } from '@/shared/types/app-release'
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 MobileAppPage from './MobileAppPage.vue'
// 휴대폰 받기 화면 /app (디자인 원본 SettingsMobileApp B · README "휴대폰 앱 받기 › 휴대폰 받기 화면 /app")
// B1 받을 앱 있음 — 카드 · 설치 방법 · 아이폰 · 문의 줄 / B2 받을 앱 없음 — 안내와 문의 줄만 / 받지 못함 — 사전 문구와 [다시 시도]
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
const CONTACT = 'IT 담당 홍길동 · 02-000-0000 · 사내 메신저 @it'
/**
* 올린 때는 지역 시각 11월 18일 10:23:45 로 만든다 — 화면은 브라우저 시간대의 날짜라, 어느 시간대에서 돌려도 "2026년 11월 18일"이다
* (시간대도 입력이다 — testing 규칙. UTC 로 적은 고정값은 UTC−1:24 보다 서쪽에서 17일이 된다)
*/
const RELEASE: AppRelease = {
version: '1.0.0',
sizeBytes: 32505856,
releasedAt: new Date(2026, 10, 18, 10, 23, 45).toISOString(),
}
const FILE_URL = '/api/app-releases/android/latest/file'
interface Replies {
release?: () => MockResult
contact?: () => MockResult
me?: () => MockResult
}
describe('MobileAppPage', () => {
let pinia: Pinia
let requests: MockRequest[]
let downloads: string[]
const mounted: VueWrapper[] = []
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated(USER)
downloads = []
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) {
downloads.push(this.getAttribute('href') ?? '')
})
})
afterEach(() => {
for (const wrapper of mounted.splice(0)) {
if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
}
vi.restoreAllMocks()
useToastStore().clear()
restoreApiAdapter()
document.body.innerHTML = ''
})
async function mountPage(replies: Replies = {}) {
requests = installMockApi((request) => {
switch (request.url) {
case '/app-releases/android/latest':
return (replies.release ?? (() => ok(RELEASE)))()
case '/support-contact':
return (replies.contact ?? (() => ok({ text: CONTACT })))()
case '/auth/me':
return (replies.me ?? (() => ok(USER)))()
default:
return fail(404, 'RES_001')
}
})
const router = createTestRouter()
await router.push('/app')
await router.isReady()
const wrapper = mount(MobileAppPage, { attachTo: document.body, global: { plugins: [pinia, router] } })
mounted.push(wrapper)
await flushPromises()
return wrapper
}
it('앱 틀 없이 브랜드 띠(로고 · 제목 · 설명)와 내용 — 레일 · 상단 바가 없다', async () => {
const wrapper = await mountPage()
const band = wrapper.get('header')
expect(band.get('img').attributes('alt')).toBe('ARA SYSTEM')
expect(band.get('h1').text()).toBe('휴대폰 앱 받기')
expect(band.text()).toContain('안드로이드 휴대폰에 설치하면 메일을 읽고 보내고, 새 메일 알림도 받을 수 있습니다.')
expect(wrapper.find('main').exists()).toBe(true)
expect(wrapper.find('nav').exists()).toBe(false)
})
describe('B1 받을 앱 있음', () => {
it('카드 — 이름 · "안드로이드 앱" · 버전 / 올린 날 / 크기 · [앱 내려받기](전체 폭, 크기까지 읽어 준다)', async () => {
const wrapper = await mountPage()
const card = wrapper.get('[data-mobile-app-release]')
expect(card.text()).toContain('ARA SYSTEM')
expect(card.text()).toContain('안드로이드 앱')
const facts = card.get('dl[data-mobile-app-facts]')
expect(facts.findAll('dt').map((term) => term.text())).toEqual(['버전', '올린 날', '크기'])
expect(facts.findAll('dd').map((value) => value.text())).toEqual(['1.0.0', '2026년 11월 18일', '31 MB'])
const download = card.get('a[data-app-download]')
expect(download.classes()).toContain('app-download--block')
expect(download.text()).toBe('앱 내려받기')
expect(download.attributes('aria-label')).toBe('앱 내려받기 (31 MB)')
expect(download.attributes('href')).toBe(FILE_URL)
expect(requests.map((request) => `${request.method} ${request.url}`)).toEqual([
'GET /app-releases/android/latest',
'GET /support-contact',
])
})
it('설치 방법 1~4 + 새 버전 한 줄 · 아이폰 안내 · 문의 줄(연락처가 있으면 "설치가 잘 되지 않나요?" 다음 줄에)', async () => {
const wrapper = await mountPage()
expect(wrapper.get('#app-page-steps-title').text()).toBe('설치 방법')
const steps = wrapper.findAll('[data-app-steps] li')
expect(steps.map((step) => step.text().slice(1))).toEqual([
'[앱 내려받기]를 눌러 파일을 받아 주세요. 브라우저가 한 번 더 물으면 그대로 받아 주세요.',
'받은 파일을 열어 주세요. 처음에는 알 수 없는 앱의 설치를 허용할지 묻습니다. 허용한 뒤 설치를 이어 가 주세요.',
'Play 프로텍트가 경고해도 이 화면에서 받은 ARA SYSTEM 앱이면 설치를 이어 가 주세요.',
'설치한 앱을 열고 웹메일과 같은 메일 주소와 비밀번호로 로그인해 주세요.',
])
expect(wrapper.text()).toContain(
'새 버전도 이 화면에서 받아 설치하면 됩니다. 쓰던 앱을 지우지 않고 설치하면 로그인과 설정이 그대로 남습니다.',
)
expect(wrapper.get('[data-app-iphone-note] a').attributes('href')).toBe('/settings/account/mail-clients')
const line = wrapper.get('[data-support-line]')
expect(line.classes()).toContain('support-line--base')
expect(line.text()).toContain('설치가 잘 되지 않나요?')
expect(line.get('[data-support-contact]').text()).toBe(CONTACT)
})
it('연락처가 없으면 문의 줄은 대신 쓰는 글 한 줄', async () => {
const wrapper = await mountPage({ contact: () => ok({ text: ' ' }) })
expect(wrapper.get('[data-support-line]').text()).toBe('설치가 잘 되지 않으면 관리자에게 문의해 주세요.')
})
it('[앱 내려받기] — 세션을 확인한 뒤 같은 주소로 받는다', async () => {
const wrapper = await mountPage()
await wrapper.get('a[data-app-download]').trigger('click')
await flushPromises()
expect(requests.map((request) => request.url)).toContain('/auth/me')
expect(downloads).toEqual([FILE_URL])
})
})
describe('B2 받을 앱 없음 (404 RES_001)', () => {
it('안내와 문의 줄만 — 카드 · 설치 방법 · 아이폰 안내는 없고 알리지 않는다', async () => {
const wrapper = await mountPage({ release: () => fail(404, 'RES_001') })
const none = wrapper.get('[data-mobile-app-none]')
expect(none.get('h2').text()).toBe('아직 받을 수 있는 앱이 없습니다')
expect(none.text()).toContain('앱이 준비되면 이 화면에서 받을 수 있습니다.')
expect(none.get('[data-support-line]').text()).toContain('언제 받을 수 있는지 궁금하신가요?')
expect(none.get('[data-support-contact]').text()).toBe(CONTACT)
expect(wrapper.find('[data-mobile-app-release]').exists()).toBe(false)
expect(wrapper.find('[data-app-steps]').exists()).toBe(false)
expect(wrapper.find('[data-app-iphone-note]').exists()).toBe(false)
expect(useToastStore().toasts).toEqual([])
})
it('연락처가 없으면 "언제 받을 수 있는지는 관리자에게 문의해 주세요."', async () => {
const wrapper = await mountPage({ release: () => fail(404, 'RES_001'), contact: () => ok({ text: null }) })
expect(wrapper.get('[data-mobile-app-none] [data-support-line]').text()).toBe(
'언제 받을 수 있는지는 관리자에게 문의해 주세요.',
)
})
})
it('받지 못하면 카드 자리에 사전 문구와 [다시 시도] — 알리지 않고, 누르면 다시 받는다', async () => {
let reply: MockResult = 'network'
const wrapper = await mountPage({ release: () => reply })
const alert = wrapper.get('main [role="alert"]')
expect(alert.text()).toContain(ERROR_CODE_MESSAGES.SYS_001)
expect(wrapper.find('[data-mobile-app-release]').exists()).toBe(false)
expect(wrapper.find('[data-mobile-app-none]').exists()).toBe(false)
expect(useToastStore().toasts).toEqual([])
reply = ok(RELEASE)
await alert.get('button').trigger('click')
await flushPromises()
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
expect(wrapper.get('[data-mobile-app-release]').text()).toContain('1.0.0')
})
})
@@ -0,0 +1,398 @@
<script setup lang="ts">
// 휴대폰 받기 화면 — /app (디자인 원본 SettingsMobileApp B · README "휴대폰 앱 받기 › 휴대폰 받기 화면 /app") · 탭 제목 "휴대폰 앱 받기 · ARA SYSTEM"
// 설정 화면의 QR 코드와 앱의 [새 버전 받기]가 휴대폰 브라우저로 이 주소를 연다.
// 독립 화면: 앱 레일 · 상단 바가 없다(인쇄 화면처럼 앱 틀 밖의 보호 화면). 로그인해야 열린다 — 로그인하지 않은 브라우저는 라우터 가드가
// 지금의 로그인 화면으로 보냈다가(?redirect=/app) 로그인을 마치면 이 화면으로 돌아온다(새 비밀번호 · 2단계 켜기 단계가 걸린 직원도 마친 뒤 돌아온다).
// - 브랜드 띠(#0B3640 — 로그인 브랜드 패널을 줄인 꼴: 로고 · 제목 · 설명) + 바탕 #F8F9FB 의 내용
// - 받을 앱 있음(B1): 카드(앱 그림 52 · 이름 · "안드로이드 앱" · 버전 / 올린 날 / 크기 · [앱 내려받기] 전체 폭 48) → 설치 방법 1~4 + 새 버전 한 줄 →
// 아이폰 안내 → 문의 줄("설치가 잘 되지 않나요?")
// - 받을 앱 없음(B2 — 404 RES_001, 알림 없음): 안내와 문의 줄("언제 받을 수 있는지 …")만
// - 받지 못함: 카드 자리에 사전 문구와 [다시 시도] (MailLoadError). 받는 동안은 자리만
// - [앱 내려받기]는 첨부처럼 누르기 직전에 세션을 확인한다(MobileAppDownloadLink — confirmSession). 세션이 끝났으면 세션 만료 창
// - 휴대폰 폭이 먼저다. 폭 480 보다 넓으면 띠는 화면 폭 전체로 두고 로고 · 제목 · 내용은 가운데 칸 480 에 맞춘다(B3)
import { computed } from 'vue'
import logoFull from '@/assets/brand/logo-arasystem-white.png'
import AppIcon from '@/components/AppIcon.vue'
import LoginSupportLine from '@/components/auth/LoginSupportLine.vue'
import MailLoadError from '@/components/mail/MailLoadError.vue'
import MobileAppDownloadLink from '@/components/mobile-app/MobileAppDownloadLink.vue'
import MobileAppIcon from '@/components/mobile-app/MobileAppIcon.vue'
import MobileAppIphoneNote from '@/components/mobile-app/MobileAppIphoneNote.vue'
import MobileAppSteps from '@/components/mobile-app/MobileAppSteps.vue'
import { APP_NAME } from '@/shared/constants/brand'
import { MOBILE_APP_PAGE_TEXT, MOBILE_APP_TEXT } from '@/shared/constants/mobile-app'
import { appReleaseFacts, appReleaseSize } from '@/shared/utils/app-release'
import { useAppReleaseStore } from '@/stores/app-release.store'
import { useSupportContactStore } from '@/stores/support-contact.store'
const appReleaseStore = useAppReleaseStore()
const supportContactStore = useSupportContactStore()
/** 받을 앱 — 받은 판이 있을 때만 */
const release = computed(() => (appReleaseStore.status === 'ready' ? appReleaseStore.release : null))
const facts = computed(() => (release.value ? appReleaseFacts(release.value) : []))
const size = computed(() => (release.value ? appReleaseSize(release.value) : ''))
const loadError = computed(() => (appReleaseStore.status === 'error' ? appReleaseStore.error : null))
// 처음 그리기 전에 — 운영자가 새 판을 올렸을 수 있다. 문의 연락처는 받지 못하면 대신 쓰는 글 그대로(알리지 않는다)
void appReleaseStore.load()
void supportContactStore.ensureLoaded()
</script>
<template>
<div class="app-page">
<header class="app-page__band">
<div class="app-page__band-inner">
<!-- 흰 로고 — 어두운 브랜드 바탕(#0B3640) 위에만 둔다. 원본 960×459 -->
<img
:src="logoFull"
:alt="APP_NAME"
width="960"
height="459"
class="app-page__logo"
>
<div class="app-page__intro">
<h1 class="app-page__title">
{{ MOBILE_APP_PAGE_TEXT.title }}
</h1>
<p class="app-page__lead">
{{ MOBILE_APP_PAGE_TEXT.lead }}
</p>
</div>
</div>
</header>
<main class="app-page__main">
<div class="app-page__column">
<!-- 받을 앱 있음 -->
<template v-if="release">
<div
class="app-page__card"
data-mobile-app-release
>
<div class="app-page__card-head">
<MobileAppIcon large />
<div class="app-page__card-name">
<span class="app-page__name">{{ MOBILE_APP_TEXT.appName }}</span>
<span class="app-page__platform">{{ MOBILE_APP_PAGE_TEXT.platform }}</span>
</div>
</div>
<dl
class="app-page__facts"
data-mobile-app-facts
>
<template
v-for="fact in facts"
:key="fact.label"
>
<dt class="app-page__fact-label">
{{ fact.label }}
</dt>
<dd class="app-page__fact-value">
{{ fact.value }}
</dd>
</template>
</dl>
<MobileAppDownloadLink
:size="size"
block
/>
</div>
<section
class="app-page__steps"
aria-labelledby="app-page-steps-title"
>
<h2
id="app-page-steps-title"
class="app-page__section-title"
>
{{ MOBILE_APP_TEXT.stepsTitle }}
</h2>
<MobileAppSteps
:steps="MOBILE_APP_PAGE_TEXT.steps"
roomy
/>
<p class="app-page__update">
{{ MOBILE_APP_PAGE_TEXT.updateText }}
</p>
</section>
<MobileAppIphoneNote />
<div class="app-page__support">
<LoginSupportLine
:question="MOBILE_APP_PAGE_TEXT.supportQuestion"
:fallback="MOBILE_APP_PAGE_TEXT.supportFallback"
:contact="supportContactStore.text"
size="base"
/>
</div>
</template>
<!-- 받을 앱 없음 — 안내와 문의 줄만 -->
<div
v-else-if="appReleaseStore.status === 'none'"
class="app-page__none"
data-mobile-app-none
>
<AppIcon
name="smartphone"
:size="28"
:stroke-width="1.5"
class="app-page__none-icon"
/>
<h2 class="app-page__none-title">
{{ MOBILE_APP_TEXT.noneTitle }}
</h2>
<p class="app-page__none-text">
{{ MOBILE_APP_PAGE_TEXT.noneText }}
</p>
<div class="app-page__none-support">
<LoginSupportLine
:question="MOBILE_APP_TEXT.noneQuestion"
:fallback="MOBILE_APP_TEXT.noneFallback"
:contact="supportContactStore.text"
size="base"
/>
</div>
</div>
<!-- 받지 못함 — 카드 자리에 사전 문구와 [다시 시도] -->
<MailLoadError
v-else-if="loadError"
:error="loadError"
leave-label=""
@retry="appReleaseStore.load()"
/>
<div
v-else
class="app-page__placeholder"
aria-busy="true"
/>
</div>
</main>
</div>
</template>
<style scoped>
/* 휴대폰 폭(360)이 먼저다 — 넓은 화면(480 초과)은 아래 미디어 쿼리 */
.app-page {
min-height: 100vh;
display: flex;
flex-direction: column;
background: var(--color-surface-panel);
color: var(--color-text);
word-break: keep-all;
overflow-wrap: anywhere;
}
/* 브랜드 띠 — 로그인 브랜드 패널을 줄인 꼴 (로고 120 · 제목 20 · 설명) */
.app-page__band {
padding: var(--space-28) var(--space-20) var(--space-24);
background: var(--color-brand-surface);
}
.app-page__band-inner {
max-width: 30rem;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: var(--space-16);
}
.app-page__logo {
display: block;
width: 7.5rem;
height: auto;
}
.app-page__intro {
display: flex;
flex-direction: column;
gap: var(--space-6);
}
.app-page__title {
margin: 0;
font-size: var(--font-size-heading);
line-height: 1.35;
font-weight: 700;
letter-spacing: var(--letter-spacing-heading);
color: var(--color-on-brand);
}
.app-page__lead {
margin: 0;
font-size: var(--font-size-body);
line-height: 1.6;
color: var(--color-on-brand-muted);
}
/* 내용 — 본문 14, 가운데 칸 480 */
.app-page__main {
flex: 1 1 auto;
padding: var(--space-20) var(--space-16) var(--space-28);
font-size: var(--font-size-body);
}
.app-page__column {
width: 100%;
max-width: 30rem;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: var(--space-22);
}
/* 앱 카드 — 테두리 · 모서리 12 · 안쪽 16 */
.app-page__card {
display: flex;
flex-direction: column;
gap: var(--space-14);
padding: var(--space-16);
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-xl);
background: var(--color-surface);
}
.app-page__card-head {
display: flex;
align-items: center;
gap: var(--space-12);
}
.app-page__card-name {
min-width: 0;
display: flex;
flex-direction: column;
line-height: 1.35;
}
.app-page__name {
font-size: var(--font-size-title-md);
font-weight: 700;
letter-spacing: var(--letter-spacing-tight);
}
.app-page__platform {
font-size: var(--font-size-base);
color: var(--color-text-muted);
}
/* 버전 · 올린 날 · 크기 — 이름 칸 56 */
.app-page__facts {
margin: 0;
display: grid;
grid-template-columns: var(--space-56) minmax(0, 1fr);
row-gap: var(--space-2);
font-size: var(--font-size-base);
font-variant-numeric: tabular-nums;
}
.app-page__fact-label {
color: var(--color-text-muted);
}
.app-page__fact-value {
margin: 0;
color: var(--color-text-strong);
}
.app-page__steps {
display: flex;
flex-direction: column;
gap: var(--space-10);
}
.app-page__section-title {
margin: 0;
font-size: var(--font-size-title-sm);
font-weight: 700;
}
.app-page__update {
margin: var(--space-2) 0 0;
font-size: var(--font-size-base);
color: var(--color-text-secondary);
}
/* 문의 줄 — 위에 가는 선 */
.app-page__support {
padding-top: var(--space-16);
border-top: var(--border-width) solid var(--color-border);
}
/* 받을 앱 없음 — 테두리 상자 안에 아이콘 28 · 제목 17 · 안내 · 가는 선 아래 문의 줄 */
.app-page__none {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--space-10);
padding: var(--space-20) var(--space-16);
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-xl);
background: var(--color-surface);
}
.app-page__none-icon {
color: var(--color-icon-muted);
}
.app-page__none-title {
margin: 0;
font-size: var(--font-size-title-md);
line-height: var(--line-height-heading);
font-weight: 650;
}
.app-page__none-text {
margin: 0;
color: var(--color-text-strong);
}
.app-page__none-support {
align-self: stretch;
margin-top: var(--space-4);
padding-top: var(--space-12);
border-top: var(--border-width) solid var(--color-border-subtle);
}
.app-page__placeholder {
min-height: 16rem;
}
/* 넓은 화면(480 초과 — PC 브라우저) — 띠는 화면 폭 전체, 로고 · 제목 · 내용은 가운데 칸 480 (B3) */
@media (min-width: 30.0625rem) {
/* 띠 위 36 · 좌우 24 · 아래 32 */
.app-page__band {
padding: var(--space-36) var(--space-24) 2rem;
}
.app-page__band-inner {
gap: var(--space-18);
}
.app-page__logo {
width: 9.375rem;
}
.app-page__title {
font-size: var(--font-size-heading-lg);
}
.app-page__main {
padding: var(--space-28) var(--space-24) var(--space-40);
}
.app-page__card {
padding: var(--space-20);
}
.app-page__none {
padding: var(--space-24) var(--space-20);
}
}
</style>
@@ -0,0 +1,278 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import QrCodeImage from '@/components/QrCodeImage.vue'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import type { AppRelease } from '@/shared/types/app-release'
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 AccountMobileAppPage from './AccountMobileAppPage.vue'
// 설정 › 계정 › 휴대폰 앱 (디자인 원본 SettingsMobileApp A · README "휴대폰 앱 받기 › 설정 › 계정 › 휴대폰 앱")
// A1 받을 앱 있음 — 앱 칸 · QR · 설치 방법 · 새 버전 · 아이폰 / A2 받을 앱 없음 — 안내와 문의 줄만 / 받지 못함 — 사전 문구와 [다시 시도]
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
const CONTACT = 'IT 담당 홍길동 · 02-000-0000 · 사내 메신저 @it'
/**
* 올린 때는 지역 시각 11월 18일 10:23:45 로 만든다 — 화면은 브라우저 시간대의 날짜라, 어느 시간대에서 돌려도 "2026년 11월 18일"이다
* (시간대도 입력이다 — testing 규칙. UTC 로 적은 고정값은 UTC−1:24 보다 서쪽에서 17일이 된다)
*/
const RELEASE: AppRelease = {
version: '1.0.0',
sizeBytes: 32505856,
releasedAt: new Date(2026, 10, 18, 10, 23, 45).toISOString(),
}
const FILE_URL = '/api/app-releases/android/latest/file'
interface Replies {
release?: () => MockResult | Promise<MockResult>
contact?: () => MockResult
me?: () => MockResult | Promise<MockResult>
refresh?: () => MockResult
}
describe('AccountMobileAppPage', () => {
let pinia: Pinia
let requests: MockRequest[]
/** 실제 내려받기를 시작하는 임시 <a download> 의 클릭 — jsdom 은 이동하지 못하므로 가로채 기록만 한다 */
let downloads: string[]
const mounted: VueWrapper[] = []
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated(USER)
downloads = []
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) {
downloads.push(this.getAttribute('href') ?? '')
})
})
afterEach(() => {
for (const wrapper of mounted.splice(0)) {
if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
}
vi.restoreAllMocks()
useToastStore().clear()
restoreApiAdapter()
document.body.innerHTML = ''
})
async function mountPage(replies: Replies = {}) {
requests = installMockApi((request) => {
switch (request.url) {
case '/app-releases/android/latest':
return (replies.release ?? (() => ok(RELEASE)))()
case '/support-contact':
return (replies.contact ?? (() => ok({ text: CONTACT })))()
case '/auth/me':
return (replies.me ?? (() => ok(USER)))()
case '/auth/refresh':
return (replies.refresh ?? (() => ok(USER)))()
default:
return fail(404, 'RES_001')
}
})
const router = createTestRouter()
await router.push('/settings/account/mobile-app')
await router.isReady()
const wrapper = mount(AccountMobileAppPage, { 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('머리 · 「안드로이드 앱」 칸 — 앱 이름과 "버전 · 올린 날 · 크기" 한 줄, [앱 내려받기]는 크기까지 읽어 준다', async () => {
const wrapper = await mountPage()
expect(wrapper.get('h1').text()).toBe('휴대폰 앱')
expect(wrapper.text()).toContain(
'안드로이드 휴대폰에 ARA SYSTEM 앱을 설치하면 메일을 읽고 보내고, 새 메일 알림도 받을 수 있습니다.',
)
expect(wrapper.get('#mobile-app-android-title').text()).toBe('안드로이드 앱')
const box = wrapper.get('[data-mobile-app-release]')
expect(box.text()).toContain('ARA SYSTEM')
expect(box.get('[data-mobile-app-facts]').text()).toBe('버전 1.0.0 · 올린 날 2026년 11월 18일 · 크기 31 MB')
const download = box.get('a[data-app-download]')
expect(download.text()).toBe('앱 내려받기')
expect(download.attributes('aria-label')).toBe('앱 내려받기 (31 MB)')
expect(download.attributes('href')).toBe(FILE_URL)
expect(download.attributes('download')).toBe('')
expect(requestLines()).toEqual(['GET /app-releases/android/latest', 'GET /support-contact'])
})
it('QR 은 지금 주소의 출처 + /app 만 담는다 — 휴대폰으로 받기 · 주소를 직접 넣는 길 · PC 에서 받으면 옮겨야 한다는 줄', async () => {
const wrapper = await mountPage()
const qr = wrapper.getComponent(QrCodeImage)
expect(qr.props('value')).toBe(`${window.location.origin}/app`)
expect(wrapper.get('[data-qr-code]').attributes('aria-label')).toBe('휴대폰에서 앱을 받는 주소의 QR 코드')
const phone = wrapper.get('[data-mobile-app-phone]')
expect(phone.get('h3').text()).toBe('휴대폰으로 받기')
expect(phone.text()).toContain(
'휴대폰 카메라로 QR 코드를 찍어 주세요. 휴대폰 브라우저에서 웹메일에 로그인하면 바로 앱을 받을 수 있습니다.',
)
expect(phone.text()).toContain('QR 코드를 찍을 수 없으면 휴대폰 브라우저의 주소 칸에 아래 주소를 직접 넣어 주세요.')
expect(phone.get('[data-mobile-app-address]').text()).toBe(`${window.location.host}/app`)
expect(phone.text()).toContain('PC에서 받은 파일은 휴대폰으로 옮겨야 설치할 수 있습니다.')
})
it('설치 방법 1~4(둘째 줄에 받는 파일 이름) · 새 버전이 나오면 · 아이폰 안내(메일 프로그램 연결 링크)', async () => {
const wrapper = await mountPage()
expect(wrapper.get('#mobile-app-steps-title').text()).toBe('설치 방법')
const steps = wrapper.findAll('[data-app-steps] li')
expect(steps).toHaveLength(4)
expect(steps.map((step) => step.find('[aria-hidden="true"]').text())).toEqual(['1', '2', '3', '4'])
expect(steps[1]?.text()).toContain('받은 ‘ARA-SYSTEM-1.0.0.apk’ 파일을 열어 주세요.')
expect(steps[3]?.text()).toContain('앱 비밀번호는 만들지 않으셔도 됩니다.')
expect(wrapper.get('#mobile-app-update-title').text()).toBe('새 버전이 나오면')
expect(wrapper.text()).toContain('꼭 필요한 업데이트가 있으면 앱이 먼저 알려 드립니다.')
const iphone = wrapper.get('[data-app-iphone-note]')
expect(iphone.text()).toContain('아이폰용 앱은 아직 없습니다')
expect(iphone.get('a').text()).toBe('메일 프로그램 연결')
expect(iphone.get('a').attributes('href')).toBe('/settings/account/mail-clients')
// 받을 앱이 있으면 문의 줄은 없다
expect(wrapper.find('[data-support-line]').exists()).toBe(false)
})
it('받는 동안은 칸 자리만 둔다 (aria-busy) — QR · 설치 방법은 받은 뒤에', async () => {
let release: (result: MockResult) => void = () => {}
const gate = new Promise<MockResult>((settle) => {
release = settle
})
const wrapper = await mountPage({ release: () => gate })
expect(wrapper.find('section [aria-busy="true"]').exists()).toBe(true)
expect(wrapper.find('[data-mobile-app-release]').exists()).toBe(false)
expect(wrapper.find('[data-app-steps]').exists()).toBe(false)
release(ok(RELEASE))
await flushPromises()
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(false)
expect(wrapper.find('[data-mobile-app-release]').exists()).toBe(true)
})
})
describe('A2 받을 앱 없음 (404 RES_001)', () => {
it('안내와 문의 줄만 — QR · 설치 방법 · 새 버전 · 아이폰 안내는 없고 알리지 않는다', async () => {
const wrapper = await mountPage({ release: () => fail(404, 'RES_001') })
const none = wrapper.get('[data-mobile-app-none]')
expect(none.text()).toContain('아직 받을 수 있는 앱이 없습니다')
expect(none.text()).toContain('앱이 준비되면 이곳에서 받아 설치할 수 있습니다.')
expect(wrapper.find('[data-mobile-app-release]').exists()).toBe(false)
expect(wrapper.find('[data-qr-code]').exists()).toBe(false)
expect(wrapper.find('[data-app-steps]').exists()).toBe(false)
expect(wrapper.find('[data-app-iphone-note]').exists()).toBe(false)
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
expect(useToastStore().toasts).toEqual([])
})
it('연락처가 있으면 물음 다음 줄에 연락처', async () => {
const wrapper = await mountPage({ release: () => fail(404, 'RES_001') })
const line = wrapper.get('[data-mobile-app-none] [data-support-line]')
expect(line.text()).toContain('언제 받을 수 있는지 궁금하신가요?')
expect(line.get('[data-support-contact]').text()).toBe(CONTACT)
expect(line.find('br').exists()).toBe(true)
})
it('연락처를 정하지 않았거나 받지 못하면 대신 쓰는 글 한 줄', async () => {
const unset = await mountPage({ release: () => fail(404, 'RES_001'), contact: () => ok({ text: null }) })
expect(unset.get('[data-support-line]').text()).toBe('언제 받을 수 있는지는 관리자에게 문의해 주세요.')
expect(unset.find('[data-support-contact]').exists()).toBe(false)
unset.unmount()
setActivePinia((pinia = createPinia()))
useAuthStore().markAuthenticated(USER)
const failed = await mountPage({ release: () => fail(404, 'RES_001'), contact: () => fail(503, 'SYS_001') })
expect(failed.get('[data-support-line]').text()).toBe('언제 받을 수 있는지는 관리자에게 문의해 주세요.')
expect(useToastStore().toasts).toEqual([])
})
})
describe('받지 못함', () => {
it('칸 자리에 사전 문구와 [다시 시도] — 알림은 띄우지 않고, 누르면 다시 받는다', async () => {
let reply: MockResult = fail(503, 'SYS_001')
const wrapper = await mountPage({ release: () => reply })
const alert = wrapper.get('section [role="alert"]')
expect(alert.text()).toContain(ERROR_CODE_MESSAGES.SYS_001)
expect(alert.text()).toContain('문의 코드 3f1c0a2e')
expect(wrapper.find('[data-mobile-app-release]').exists()).toBe(false)
expect(wrapper.find('[data-mobile-app-none]').exists()).toBe(false)
expect(useToastStore().toasts).toEqual([])
reply = ok(RELEASE)
await alert.get('button').trigger('click')
await flushPromises()
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
expect(wrapper.find('[data-mobile-app-release]').exists()).toBe(true)
expect(requestLines().filter((line) => line.endsWith('/latest'))).toHaveLength(2)
})
})
describe('[앱 내려받기] — 이동 전 세션 확인', () => {
it('누르면 세션을 확인한 뒤 같은 주소로 받는다 — 원래 링크의 이동은 막고, 확인 중에 다시 눌러도 한 번만', async () => {
let releaseMe: (result: MockResult) => void = () => {}
const meGate = new Promise<MockResult>((settle) => {
releaseMe = settle
})
const wrapper = await mountPage({ me: () => meGate })
const link = wrapper.get<HTMLAnchorElement>('a[data-app-download]').element
const first = new MouseEvent('click', { bubbles: true, cancelable: true })
link.dispatchEvent(first)
link.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
await flushPromises()
expect(first.defaultPrevented).toBe(true)
expect(downloads).toEqual([])
releaseMe(ok(USER))
await flushPromises()
expect(requestLines().filter((line) => line.includes('/auth/'))).toEqual(['GET /auth/me'])
expect(downloads).toEqual([FILE_URL])
})
it('세션이 끝났으면 받지 않고 세션 만료 창으로 (갱신도 실패)', async () => {
const wrapper = await mountPage({ me: () => fail(401, 'AUTH_001'), refresh: () => fail(401, 'AUTH_001') })
await wrapper.get('a[data-app-download]').trigger('click')
await flushPromises()
expect(requestLines().filter((line) => line.includes('/auth/'))).toEqual(['GET /auth/me', 'POST /auth/refresh'])
expect(downloads).toEqual([])
expect(useAuthStore().sessionExpired).toBe(true)
})
})
it('세션이 바뀐 뒤 늦게 온 앱 정보는 쓰지 않는다', async () => {
let release: (result: MockResult) => void = () => {}
const gate = new Promise<MockResult>((settle) => {
release = settle
})
const wrapper = await mountPage({ release: () => gate })
useAuthStore().clearAuth()
release(ok(RELEASE))
await flushPromises()
expect(wrapper.find('[data-mobile-app-release]').exists()).toBe(false)
})
})
@@ -0,0 +1,383 @@
<script setup lang="ts">
// 설정 › 계정 › 휴대폰 앱 (디자인 원본 SettingsMobileApp A · README "휴대폰 앱 받기 › 설정 › 계정 › 휴대폰 앱") — /settings/account/mobile-app · 탭 제목 "휴대폰 앱"
// 안드로이드 앱(APK)은 스토어에 올리지 않고 웹메일에 로그인한 직원만 받는다. PC 에서 보는 화면이다.
// 머리 + 설명 → 「안드로이드 앱」 칸:
// - 받을 앱 있음(A1): 앱 그림 · 이름 · "버전 · 올린 날 · 크기" · [앱 내려받기] / 아래 줄에 QR(지금 주소의 출처 + /app — 비밀 아님) · 「휴대폰으로 받기」 ·
// 주소를 직접 넣는 길 · PC 에서 받으면 옮겨야 한다는 줄. 그 뒤 「설치 방법」 1~4 · 「새 버전이 나오면」 · 아이폰 안내(메일 프로그램 연결 링크)
// - 받을 앱 없음(A2 — 404 RES_001, 알림 없음): 안내와 문의 줄만(문의 연락처가 있으면 물음 다음 줄에 연락처, 없거나 받지 못하면 대신 쓰는 글 한 줄)
// - 받지 못함: 칸 자리에 사전 문구와 [다시 시도] (MailLoadError). 받는 동안은 자리만
// - 들어올 때마다 새로 받는다(app-release.store — 운영자가 새 판을 올렸을 수 있다). 세션이 바뀐 뒤 늦게 온 응답은 쓰지 않는다
// - [앱 내려받기]는 첨부처럼 이동 직전에 세션을 확인한다(MobileAppDownloadLink — confirmSession)
// - 좁은 폭(휴대폰 — 480 이하): 설정 메뉴는 위로(지금 설정 셸), QR 줄은 빼고 [앱 내려받기]는 이름 아래 전체 폭
import { computed } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import LoginSupportLine from '@/components/auth/LoginSupportLine.vue'
import MailLoadError from '@/components/mail/MailLoadError.vue'
import MobileAppDownloadLink from '@/components/mobile-app/MobileAppDownloadLink.vue'
import MobileAppIcon from '@/components/mobile-app/MobileAppIcon.vue'
import MobileAppIphoneNote from '@/components/mobile-app/MobileAppIphoneNote.vue'
import MobileAppSteps from '@/components/mobile-app/MobileAppSteps.vue'
import QrCodeImage from '@/components/QrCodeImage.vue'
import SettingsPageFrame from '@/components/settings/SettingsPageFrame.vue'
import { MOBILE_APP_SETTINGS_TEXT, MOBILE_APP_TEXT } from '@/shared/constants/mobile-app'
import {
addressWithoutScheme,
appReleaseFactsLine,
appReleaseFileName,
appReleaseSize,
mobileAppPageUrl,
} from '@/shared/utils/app-release'
import { useAppReleaseStore } from '@/stores/app-release.store'
import { useSupportContactStore } from '@/stores/support-contact.store'
const appReleaseStore = useAppReleaseStore()
const supportContactStore = useSupportContactStore()
/** QR 이 담는 주소 — 지금 주소의 출처 + /app ("https://portal.arasystem.it.kr/app") */
const pageUrl = mobileAppPageUrl(window.location.origin)
/** 휴대폰 브라우저의 주소 칸에 넣을 글 ("portal.arasystem.it.kr/app") */
const pageAddress = addressWithoutScheme(pageUrl)
/** 받을 앱 — 받은 판이 있을 때만 */
const release = computed(() => (appReleaseStore.status === 'ready' ? appReleaseStore.release : null))
const factsLine = computed(() => (release.value ? appReleaseFactsLine(release.value) : ''))
const size = computed(() => (release.value ? appReleaseSize(release.value) : ''))
const steps = computed(() => MOBILE_APP_SETTINGS_TEXT.steps(appReleaseFileName(release.value?.version ?? '')))
const loadError = computed(() => (appReleaseStore.status === 'error' ? appReleaseStore.error : null))
// 처음 그리기 전에 — 운영자가 새 판을 올렸을 수 있다. 문의 연락처는 받지 못하면 대신 쓰는 글 그대로(알리지 않는다)
void appReleaseStore.load()
void supportContactStore.ensureLoaded()
</script>
<template>
<SettingsPageFrame
title-id="mobile-app-page-title"
:title="MOBILE_APP_SETTINGS_TEXT.title"
:description="MOBILE_APP_SETTINGS_TEXT.description"
>
<section
class="mobile-app__section"
aria-labelledby="mobile-app-android-title"
>
<h2
id="mobile-app-android-title"
class="mobile-app__section-title"
>
{{ MOBILE_APP_SETTINGS_TEXT.androidTitle }}
</h2>
<!-- 받을 앱 있음 — 앱 줄 + QR 줄 -->
<div
v-if="release"
class="mobile-app__box"
data-mobile-app-release
>
<div class="mobile-app__app">
<MobileAppIcon />
<div class="mobile-app__app-text">
<span class="mobile-app__app-name">{{ MOBILE_APP_TEXT.appName }}</span>
<p
class="mobile-app__facts"
data-mobile-app-facts
>
{{ factsLine }}
</p>
</div>
<MobileAppDownloadLink
class="mobile-app__download"
:size="size"
/>
</div>
<div
class="mobile-app__phone"
data-mobile-app-phone
>
<div class="mobile-app__qr-frame">
<QrCodeImage
:value="pageUrl"
:label="MOBILE_APP_SETTINGS_TEXT.qrLabel"
/>
</div>
<div class="mobile-app__phone-text">
<h3 class="mobile-app__phone-title">
{{ MOBILE_APP_SETTINGS_TEXT.phoneTitle }}
</h3>
<p class="mobile-app__copy">
{{ MOBILE_APP_SETTINGS_TEXT.phoneScan }}
</p>
<div class="mobile-app__typing">
<p class="mobile-app__copy">
{{ MOBILE_APP_SETTINGS_TEXT.phoneTyping }}
</p>
<p
class="mobile-app__address"
data-mobile-app-address
>
{{ pageAddress }}
</p>
</div>
<p class="mobile-app__pc-note">
{{ MOBILE_APP_SETTINGS_TEXT.pcNote }}
</p>
</div>
</div>
</div>
<!-- 받을 앱 없음 — 안내와 문의 줄만 -->
<div
v-else-if="appReleaseStore.status === 'none'"
class="mobile-app__none"
data-mobile-app-none
>
<AppIcon
name="smartphone"
:size="24"
:stroke-width="1.5"
class="mobile-app__none-icon"
/>
<div class="mobile-app__none-text">
<span class="mobile-app__none-title">{{ MOBILE_APP_TEXT.noneTitle }}</span>
<span class="mobile-app__none-hint">{{ MOBILE_APP_SETTINGS_TEXT.noneText }}</span>
</div>
<div class="mobile-app__none-support">
<LoginSupportLine
:question="MOBILE_APP_TEXT.noneQuestion"
:fallback="MOBILE_APP_TEXT.noneFallback"
:contact="supportContactStore.text"
/>
</div>
</div>
<!-- 받지 못함 — 칸 자리에 사전 문구와 [다시 시도] -->
<MailLoadError
v-else-if="loadError"
:error="loadError"
leave-label=""
@retry="appReleaseStore.load()"
/>
<div
v-else
class="mobile-app__placeholder"
aria-busy="true"
/>
</section>
<template v-if="release">
<section
class="mobile-app__steps"
aria-labelledby="mobile-app-steps-title"
>
<h2
id="mobile-app-steps-title"
class="mobile-app__section-title"
>
{{ MOBILE_APP_TEXT.stepsTitle }}
</h2>
<MobileAppSteps :steps="steps" />
</section>
<section
class="mobile-app__update"
aria-labelledby="mobile-app-update-title"
>
<h2
id="mobile-app-update-title"
class="mobile-app__section-title"
>
{{ MOBILE_APP_SETTINGS_TEXT.updateTitle }}
</h2>
<p
v-for="text in MOBILE_APP_SETTINGS_TEXT.updateTexts"
:key="text"
class="mobile-app__copy"
>
{{ text }}
</p>
</section>
<MobileAppIphoneNote />
</template>
</SettingsPageFrame>
</template>
<style scoped>
.mobile-app__section {
display: flex;
flex-direction: column;
gap: var(--space-8);
}
.mobile-app__section-title {
margin: 0;
font-size: var(--font-size-body);
font-weight: 700;
}
/* 앱 칸 — 테두리 · 모서리 10, 앱 줄과 QR 줄 사이에 가는 선 */
.mobile-app__box {
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-surface);
}
/* 앱 줄 — 그림 48 · 이름 · 정보 · [앱 내려받기](44). 좁으면 단추가 이름 아래 전체 폭으로 내려간다 */
.mobile-app__app {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-14);
padding: var(--space-16);
}
.mobile-app__app-text {
flex: 1 1 0;
min-width: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
line-height: var(--line-height-heading);
}
.mobile-app__app-name {
font-size: var(--font-size-body);
font-weight: 600;
}
.mobile-app__facts {
margin: 0;
font-size: var(--font-size-label);
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
.mobile-app__download {
flex: 0 0 auto;
}
/* QR 줄 — QR 160(상자 안쪽 6 · 테두리) + 글 */
.mobile-app__phone {
display: flex;
align-items: flex-start;
gap: var(--space-20);
padding: var(--space-16);
border-top: var(--border-width) solid var(--color-border-subtle);
}
.mobile-app__qr-frame {
flex: 0 0 auto;
padding: var(--space-6);
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface);
}
.mobile-app__phone-text {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
gap: var(--space-8);
padding-top: var(--space-2);
}
.mobile-app__phone-title {
margin: 0;
font-size: var(--font-size-emphasis);
font-weight: 600;
}
.mobile-app__copy {
margin: 0;
color: var(--color-text-strong);
}
.mobile-app__typing {
display: flex;
flex-direction: column;
gap: var(--space-4);
padding-top: var(--space-10);
border-top: var(--border-width) solid var(--color-border-subtle);
}
.mobile-app__address {
margin: 0;
font-size: var(--font-size-body);
font-weight: 600;
color: var(--color-text);
}
.mobile-app__pc-note {
margin: 0;
font-size: var(--font-size-label);
color: var(--color-text-muted);
}
/* 받을 앱 없음 — 테두리 상자 안에 아이콘 24 · 제목 · 안내 · 가는 선 아래 문의 줄 */
.mobile-app__none {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--space-10);
padding: var(--space-20);
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-surface);
}
.mobile-app__none-icon {
color: var(--color-icon-muted);
}
.mobile-app__none-text {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.mobile-app__none-title {
font-size: var(--font-size-body);
font-weight: 600;
}
.mobile-app__none-hint {
color: var(--color-text-secondary);
}
.mobile-app__none-support {
align-self: stretch;
margin-top: var(--space-2);
padding-top: var(--space-12);
border-top: var(--border-width) solid var(--color-border-subtle);
}
.mobile-app__placeholder {
min-height: 16rem;
}
.mobile-app__steps {
display: flex;
flex-direction: column;
gap: var(--space-10);
}
.mobile-app__update {
display: flex;
flex-direction: column;
gap: var(--space-6);
}
/* 좁은 폭(휴대폰) — QR 줄은 빼고(휴대폰에서는 [앱 내려받기]로 바로 받는다) 단추는 이름 아래 전체 폭 */
@media (max-width: 30rem) {
.mobile-app__phone {
display: none;
}
.mobile-app__download {
flex: 1 1 100%;
}
}
</style>
@@ -9,8 +9,9 @@ import { useAuthStore } from '@/stores/auth.store'
import { createTestRouter } from '@/test-utils/router'
import SettingsLayout from './SettingsLayout.vue'
// 설정 메뉴 — 계정 › 비밀번호 · 2단계 인증 · 앱 비밀번호 · 로그인 기록 / 메일 › 서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 · 차단한 주소 · 대용량 첨부 · 알림
// (디자인 README "4단계 › 설정 메뉴" · "5단계" · "6단계" · "9단계 › 설정 · 보내기 · 읽기 · 차단한 주소" · "11단계 › 설정 › 메일 › 대용량 첨부" · "12단계 — 계정 보안")
// 설정 메뉴 — 계정 › 비밀번호 · 2단계 인증 · 앱 비밀번호 · 메일 프로그램 연결 · 휴대폰 앱 · 로그인 기록 / 메일 › 서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 · 차단한 주소 · 대용량 첨부 · 알림
// (디자인 README "4단계 › 설정 메뉴" · "5단계" · "6단계" · "9단계 › 설정 · 보내기 · 읽기 · 차단한 주소" · "11단계 › 설정 › 메일 › 대용량 첨부" · "12단계 — 계정 보안" ·
// "13단계" · "휴대폰 앱 받기")
describe('SettingsLayout', () => {
let pinia: Pinia
@@ -35,7 +36,7 @@ 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="설정 메뉴"]')
@@ -48,6 +49,7 @@ describe('SettingsLayout', () => {
['2단계 인증', '/settings/account/two-factor'],
['앱 비밀번호', '/settings/account/app-passwords'],
['메일 프로그램 연결', '/settings/account/mail-clients'],
['휴대폰 앱', '/settings/account/mobile-app'],
['로그인 기록', '/settings/account/login-history'],
],
[
@@ -145,6 +147,24 @@ describe('SettingsLayout', () => {
expect(clock.findAll('path').map((shape) => shape.attributes('d'))).toContain('M3 3v5h5')
})
it('설정 › 계정 › 휴대폰 앱 — /settings/account/mobile-app, 로그인이 필요하고 탭 제목은 메뉴 이름 (앱의 라우트 표 그대로)', async () => {
const router = createRouter({ history: createMemoryHistory(), routes })
await router.push('/settings/account/mobile-app')
expect(router.currentRoute.value.name).toBe('settings-account-mobile-app')
expect(router.currentRoute.value.meta).toMatchObject({ title: '휴대폰 앱', requiresAuth: true })
expect(router.resolve({ name: 'settings-account-mobile-app' }).fullPath).toBe('/settings/account/mobile-app')
})
it('휴대폰 앱 메뉴는 휴대폰 아이콘(smartphone) · 지금 화면이면 현재로 표시한다', async () => {
const { wrapper } = await mountAt('/settings/account/mobile-app')
const current = wrapper.get('nav[aria-label="설정 메뉴"]').get('a[aria-current="page"]')
expect(current.text()).toBe('휴대폰 앱')
expect(current.find('rect').attributes()).toMatchObject({ x: '5', y: '2', width: '14', height: '20' })
expect(current.find('line').attributes()).toMatchObject({ x1: '12', y1: '18', x2: '12.01', y2: '18' })
})
it('대용량 첨부 메뉴는 링크 아이콘 · 지금 화면이면 현재로 표시한다', async () => {
const { wrapper } = await mountAt('/settings/mail/large-files')
const current = wrapper.get('nav[aria-label="설정 메뉴"]').get('a[aria-current="page"]')