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

Merged
ttipo merged 12 commits from feat/release-notes into main 2026-11-11 06:24:46 +09:00
101 changed files with 11309 additions and 316 deletions
+1
View File
@@ -18,6 +18,7 @@ description: 세 클라이언트(backend/frontend/flutter) 공통 코딩 규칙
- 절대경로 alias 사용 (TS `@/`, Dart `package:`), 깊은 상대경로(`../../`) 지양
— backend/frontend 모두 `@/` → `src/` 로 설정되어 있다 (backend 는 `tsconfig.json` paths + jest `moduleNameMapper`)
- **자동 생성 파일은 직접 수정하지 않는다** — `*.generated.ts`, `*.g.dart` 는 생성 스크립트를 통해서만 바꾼다 ([[error-codes]])
- 소스에 보이지 않는 글자(nbsp · U+2028 · U+FEFF 등)는 `\uXXXX` 이스케이프로 적되, **저장한 뒤 바이트로 확인한다** — 편집 도구가 U+00A0 이상의 이스케이프를 실제 글자로 풀어 저장한 일이 있다(2026-11-10 — 정규식 안의 U+2028 이 줄 끝이 되어 컴파일 실패, 16단계에서는 U+FEFF). 풀리면 `String.fromCharCode(0x2028)` 처럼 글자 코드로 만든다
## 네이밍 규약
+5 -2
View File
@@ -40,7 +40,9 @@ description: Flutter 모바일 앱(flutter/) 개발 규칙 — Dart Sound Null S
| 글꼴 | `assets/fonts/` · `assets/licenses/pretendard-ofl.txt` · `core/theme/app_fonts.dart` | Pretendard 1.3.9 정적 OTF 넷(400 · 500 · 600 · 700 — 웹 npm `pretendard` 와 같은 파일). OFL 1.1 글을 `LicenseRegistry` 에 올린다(설정 › 앱 정보 › 오픈소스 라이선스) |
| **새 메일 알림 (`App1Push`)** | `lib/core/push/` — 포트 `push_messaging.dart`(FCM 은 `firebase_push_messaging.dart` — 띄우지 못하면 `UnavailablePushMessaging`) · `notification_system.dart`(채널 · 알림 지우기 · 휴대폰 설정 열기 — 안드로이드 `NewMailNotifications.kt`, 채널 `kr.it.arasystem.app/notifications`) · `push_preferences.dart`(이 휴대폰의 값 — `shared_preferences`, 참 · 거짓과 FID 하나뿐) · `push_state.dart`(상태 · 설정 줄 모습 `pushRowStatusOf` · 권하기 줄 `showsPushOffer`) · `push_controller.dart`(앱에 하나 — `PushRegistrar` 를 구현) · `push_registration.dart`(서버 저장소 · `pushRegistrarProvider`) · `push_events.dart`(앞에서 받음 · 누름 — 라우터 `core/router/push_navigation.dart` 가 누른 알림을 늘 메일 받은편지함으로) · `push_target.dart` · 글 `push_texts.dart`. 화면 `features/mail/presentation/widgets/push_offer_line.dart` · `features/settings/presentation/widgets/settings_notification_row.dart` | **보낼 조건** = 받을 수 있는 휴대폰 ∧ 서버가 보낼 수 있음(app-config `pushAvailable` — 모르면 보낼 수 있는 것으로) ∧ 메일을 쓸 수 있는 세션(세션 노티파이어의 `register` · 제한 단계는 `pause`) ∧ 이 휴대폰에서 켬 ∧ 권한 ∧ 새 메일 채널. 맞추기 `_reconcile` 하나가 PUT · DELETE 를 고른다. **PUT** 은 세션이 열릴 때 한 번 · FID 가 바뀔 때 · 켤 때만(앞으로 올 때마다가 아니다), **DELETE** 는 끌 때와 권한 · 채널이 꺼진 것을 알았을 때만 — 로그아웃 · 세션 끝은 FCM `unregister()` 만(서버 줄은 서버가 세션과 함께 지운다, 풀지 못하면 `pendingUnregister` 로 다음 실행 · 앞으로 옴에 다시). **봤음** `DELETE …/unseen` 은 앞으로 올 때 · 앞에서 받은 새 메일 알림 때, 30초 간격 + 끝 한 번 — 앱이 앞에 있을 때만 보내고, 기다리는 동안 뒤로 가면 그 자리에서 보낸다(끝까지 기다리면 그사이 온 메일까지 본 것으로 센다). 앞에서 받은 알림 뒤의 봤음은 받은 때 + 1.5초(`kPushSeenAfterMessageDelay`)보다 일찍 보내지 않는다 — 서버는 seen_at 의 초부터 같은 초를 세고 받은 시각은 초 단위라, 곧바로 보내면 열지 않은 그 메일이 다음 알림에 다시 세어진다(2026-11-08 실기기). 켜기 · 끄기는 서버가 답한 뒤에 바꾼다(실패는 그 코드의 사전 문구 — 등록 소식이 없으면 `SYS_001`). **켜기가 실패하면 이 휴대폰의 값을 끔으로 둔다**(처음 값 켬에서 허락하고 켰어도 — 스위치는 꺼진 채, 저절로 다시 하지 않는다). 서버 일은 멈춘(`pause`) 바로 그 차례에도 보내지 않는다(`_canReachServer` 가 컨트롤러의 세션 값도 본다). FCM `register` · `unregister` 는 우리가 한 줄로 세운다(플러그인이 스레드 풀에서 돈다), `onRegistered` 는 방송이라 `register` 전에 듣는다. **풀기는 기다리기 전에 등록 세대를 올리고 등록을 내려 둔다** — 풀기가 실패하거나 오래 걸려도 같은 실행의 다음 로그인이 그 뒤에 다시 등록하고(같은 FID 의 PUT 이 서버의 앞 직원 줄을 지운다), 풀기 앞의 등록은 끝나도 마친 것으로 치지 않으며 "풀지 못한 등록" 표시도 지우지 않는다. 풀기가 줄에 있는 동안 온 등록 소식은 쓰지 않는다. 앱이 앞에 있으면 FCM 이 알림을 그리지 않는다 — 앞에서 받은 새 메일 알림은 메일 목록이 바로 새 메일을 확인한다(`mail_state_watcher`). 알림 데이터는 **모듈 이름과 종류**(`{ module: 'mail', kind: 'new-mail' }` · `session-ended`). FID · 주소 · 토큰을 로그 · 디버그 출력에 남기지 않는다. 시험 도구 `test/support/push_fakes.dart`(가짜 FCM · 알림창 · 값 · 서버) · `push_screen_harness.dart`(앱 전체 + 진짜 컨트롤러 — `ProviderHarness.recordPush = false`). 다른 시험대는 기기 등록을 기록하는 가짜(`RecordingPushRegistrar`)이고 컨트롤러는 FCM 이 없어 조용하다 |
| **앱 설정 · 업데이트 화면** | `lib/core/app_config/`(`app_config.dart` — 판 비교 `AppVersion` · 모양, `app_config_repository.dart`, `app_config_controller.dart` — `appUpdateRequiredProvider` · `pushAvailableProvider`) · `core/router/session_redirect.dart`(`updateRequired` 가 세션보다 먼저) · `features/app_update/`(모듈이 아니다 — 화면 · 글) · `core/app_download/`([새 버전 받기] — 받기 화면 주소 `app_download_link.dart` · 런처 `AppDownloadLauncher` · 글, 업데이트 화면과 설정 › 앱 정보가 함께 쓴다) | `GET /api/app-config` 는 띄울 때 한 번 · 앞으로 올 때 마지막으로 받은 지 1시간이 지났으면(실패는 1분 간격으로 다시) — 알리지 않고 막지 않는다. 404 `RES_001` 은 옛 서버(최소 판 없음 · 알림 쓸 수 없음). 판은 숫자 1~3칸 · 칸마다 4자리까지(서버의 판 칸 `APP_VERSION_PATTERN` 과 같다 — `-` · `+` 뒤는 버린다), 이 앱의 판을 모르면 막지 않는다. 업데이트 화면으로 옮길 때 쓰기 화면은 마저 저장한다(`ComposeController.detach(saveRemaining: true)` — 화면이 밖에서 닫힌 때도). 앱은 스토어에 올리지 않고 사내에 직접 나눠 준다(2026-11-08 — 빌드 · 서명 · 올리기는 `flutter/README.md` "사내 배포"). [새 버전 받기]는 포털의 휴대폰 앱 받기 화면 `<API_URL 의 출처>/app`(`appDownloadPageUrlOf` — `/api` 를 뗀 출처 + 경로, 짝: 웹 `mobile-app.ts` 의 `MOBILE_APP_PATH`)을 바깥 브라우저로(`LaunchMode.externalApplication` — `canLaunchUrl` 은 쓰지 않는다) 연다. 열지 못하면 스킴 없는 주소를 담아 알린다(`openAppDownloadPage` — 두 화면이 같은 알림). 앱 설정 계약은 받는 곳 주소를 싣지 않는다 |
| **설정 탭 (`App1Shell` · `App1Push` ④)** | `lib/features/settings/` — `settings_routes.dart`(`/settings` · `/settings/licenses` · 모듈이 내놓은 절) · `presentation/pages/settings_page.dart` · `widgets/settings_notification_row.dart` · `settings_texts.dart` | 한 화면(내 정보 · 알림 · 모듈 절 · 앱 정보(앱 버전 · [새 버전 받기] — 끝 아이콘이 바깥으로 여는 아이콘, 업데이트 화면과 같은 런처 · 오픈소스 라이선스) · 문의 · [로그아웃]). 문의는 로그인 화면과 같은 저장소(`supportContactProvider`), [로그아웃]은 확인 창(`shared/widgets/confirm_dialog.dart` — 처음 초점 [취소]). 스위치 색은 테마의 `switchTheme`(보드 — 켬 청록 · 끔 테두리 · 누를 수 없음 회색) |
| **설정 탭 (`App1Shell` · `App1Push` ④)** | `lib/features/settings/` — `settings_routes.dart`(`/settings` · `/settings/licenses` · `/settings/release-notes`(+ `/:noteId`) · 모듈이 내놓은 절) · `presentation/pages/settings_page.dart` · `widgets/settings_notification_row.dart` · `settings_texts.dart` | 한 화면(내 정보 · 알림 · 모듈 절 · 앱 정보(앱 버전 · 업데이트 내역(목록 → 글, 아래 행) · [새 버전 받기] — 끝 아이콘이 바깥으로 여는 아이콘, 업데이트 화면과 같은 런처 · 오픈소스 라이선스) · 문의 · [로그아웃]). 문의는 로그인 화면과 같은 저장소(`supportContactProvider`), [로그아웃]은 확인 창(`shared/widgets/confirm_dialog.dart` — 처음 초점 [취소]). 스위치 색은 테마의 `switchTheme`(보드 — 켬 청록 · 끔 테두리 · 누를 수 없음 회색) |
| **업데이트 내역 (`ReleaseNotes` B — 2026-11-10)** | `lib/features/settings/` — data(`models/release_note.dart` · `repositories/release_notes_repository.dart`) · presentation(`pages/release_notes_page.dart` · `release_note_page.dart` · providers `release_notes_providers.dart` · `release_notes_list_controller.dart` · `release_note_controller.dart` · widgets `release_notes_parts.dart` · 글 `release_notes_texts.dart`), 달력 날짜 `core/utils/calendar_date.dart`(`CalendarDate`) + `korean_format.dart` 의 `koreanCalendarDate`, 시험 도구 `test/support/release_notes_fakes.dart`(`FakeReleaseNotesBackend` · 보드의 네 글 `kBoardReleaseNotes`) | 계약 7절(`GET /api/release-notes?cursor=&limit=` · `GET /api/release-notes/{id}` — 올리는 API 없음, 글은 서버의 글 파일). 설정 › 앱 정보의 앱 버전 다음 줄 → 목록 `/settings/release-notes` → 글 `/settings/release-notes/:noteId`(둘 다 `parentNavigatorKey: rootNavigatorKey`, 글은 목록 라우트의 자식이라 주소로 바로 열어도 아래에 목록). 목록은 `releaseNotesListProvider`(autoDispose — 화면을 열 때마다 새로 받는다 · 첫 쪽 · [다시 시도]는 `silent`, [더 보기]는 알림 + 받은 줄 그대로 · 겹친 id 는 건너뜀), 글은 `releaseNoteProvider(id)`(autoDispose family · `silent` — 없는 글 404 `RES_001` 은 [목록으로], 그 밖은 [다시 시도]). 둘 다 `releaseNotesAccountProvider`(세션의 주소 — 메일의 `mailAccountProvider` 와 같은 규칙, 모듈끼리 import 하지 않아 따로)를 듣고 세션이 바뀌면 처음부터 · 세대(`_seq`)와 시작할 때의 Ref 로 늦은 응답을 버린다. 응답 모양이 틀리면 `ReleaseNotesMalformedResponse`(`SYS_001` — 인터셉터를 다 지난 뒤라 [더 보기]만 컨트롤러가 `errorNotifierProvider` 로 알린다, 메일 `DownloadInterrupted` 와 같은 결). **날짜는 `CalendarDate` 로만**(`YYYY-MM-DD` 를 글로 나눠 쥔다 — `DateTime` 으로 바꾸지 않는다, 없는 날은 모양 틀림). 다른 글 줄은 `context.pushReplacement`(쌓지 않는다 — 뒤로는 늘 목록, go_router 17 은 void 를 돌려준다). 글은 서버 글 그대로(마크다운 해석 · 링크 없음) · 한글 줄바꿈 `KeepAllText`(시험은 `findCopy`). [더 보기]는 받는 동안 누름만 무시한다(초점 지키기 — 메일 검색 [더 찾기]와 같다) — 도는 표시가 있어 시험은 `pumpAndSettle` 대신 `pump(100ms)` |
| 불러오기 상태 틀 | `lib/shared/widgets/load_states.dart` · `load_state_texts.dart` | 가운데 상태 `StateLayout`(아이콘 원 72 · 제목 · 글 · 단추) · 받지 못함 `LoadErrorView`(사전 문구 · 문의 코드 · [다시 시도] — `RES_001` + `onLeave` 면 `leaveLabel` 단추, 둘은 함께 준다) · 뼈대 `LoadingSkeleton`(화면 낭독기 한 줄 + 막대는 읽지 않음) · `SkeletonBar`(메일은 모서리 4, 업데이트 내역은 `rounded`) · `traceCodeOf`. 2026-11-10 메일 목록(`mail_list_states.dart` 의 `MailStateLayout` · `MailListError` · `_SkeletonBar`)에서 모양 · 동작 그대로 옮겼다 — 메일 목록 · 검색 · 읽기 · 쓰기와 업데이트 내역이 쓴다. 메일 줄 꼴의 뼈대(`MailListSkeleton`) · 빈 메일함 · 목록 끝 · 30일 줄은 메일에 남았다. `MailListTexts.retry` · `traceCode` 는 `LoadStateTexts` 를 가리킨다 |
| 자리 화면 | `lib/shared/widgets/placeholder_page.dart` | 화면은 캔버스로 확인받은 뒤 만든다 — 그때까지 이름(과 갈 곳)만 보이는 자리 화면. 확정되면 라우트 묶음의 builder 를 바꾼다 |
| 예제 feature(참조용) | `lib/features/user/` | 목록 페이지네이션(`{items, pageInfo}` — 단건 + 커서 목록) 예제로만 남겼다. 앱 API 에 `/users` 는 없다 |
| 시험 도구 | `test/support/` — `fakes.dart` · `provider_harness.dart` | 가짜 서버(`FakeServer` — HttpClientAdapter) · 메모리 보안 저장소 · 손 시계(`ManualClock`) · 알림 기록(`RecordingNotifier`). 고정 대기 없이 Completer 로 차례를 맞춘다 ([[testing]]) |
@@ -103,7 +105,7 @@ lib/
│ ├── settings/ ← 모듈 — 주소 /settings/... (다른 모듈의 설정 절을 받아 붙인다)
│ └── user/ ← 예제(참조용)
└── shared/
└── widgets/ ← 앱 공통 위젯 (자리 화면 등)
└── widgets/ ← 앱 공통 위젯 (자리 화면 · 확인 창 · 불러오기 상태 틀 등)
```
---
@@ -310,6 +312,7 @@ await tokenStore.clear(byUser: true); // 로그아웃 (세션 끝은 byUser
가짜 서버는 요청 본문을 읽지 않아 보내는 진행(onSendProgress)이 오지 않는다 — 진행을 보려면 `FakeServer.readRequestBodies` 를 켠다.
InkWell 에 길게 누르기 처리가 없으면 길게 눌렀다 뗄 때 onTap 이 돈다 — 열지 않을 줄은 길게 누르기를 받아 두고 아무것도 하지 않는다(검색 결과 줄).
두 줄 TextField 에도 Scrollable 이 있다 — 목록 스크롤은 ListView 아래에서 찾는다(`find.descendant`).
go_router 17 의 `context.pushReplacement` · `replace` 는 void 다(`push` 만 Future) — 바꿔 끼울 화면은 `pushReplacement`(새 page key — 앞 화면의 PageStorage 스크롤 자리를 물려받지 않는다, 업데이트 내역의 다른 글 줄).
- [ ] **린트로 강제되는 항목**: `dynamic` 호출·`print`·`const` 누락·await 누락·작은따옴표·import 정렬 (`analysis_options.yaml`).
- [ ] **env**: `flutter/.env.example` → `.env` 복사 후 `API_URL` 설정 (Android 에뮬레이터는 `10.0.2.2`).
`.env` 는 `pubspec.yaml` 의 asset 이라 **없으면 `flutter analyze` 가 경고**한다.
+2
View File
@@ -47,6 +47,7 @@ description: NestJS 백엔드(backend/) 개발 규칙 — 컨트롤러/서비스
| 앱 새 메일 알림 (앱 1단계 — FCM) | `modules/push` · `modules/mail/new-mail-push.service.ts` · `MailEventsService.hold` · `common/guards/app-client.guard.ts` | 계약 1절 "앱 새 메일 알림 · 앱 설정". **등록** `PUT`·`DELETE /api/push-registrations/current` · `DELETE …/current/unseen`(`push-registrations.controller.ts` — `@UseGuards(JwtAuthGuard, AppClientGuard)` + `@ApiBearerAuth()`: 앱 토큰만, 웹 쿠키 세션은 403 `AUTH_003`, 제한된 세션 403 `AUTH_010`). 표 `push_registrations`(마이그레이션 `AddPushRegistrations` — 세션(sid)마다 한 줄 · FID 유일 · 세션 표와 외래 키 없음). PUT 은 한 트랜잭션(`push-registrations.repository.ts` — 세션이 살아 있는 · 제한 없는 앱 세션인지 → 같은 FID 의 다른 줄 지우기(같은 FID 는 `pg_advisory_xact_lock('PSH1', hashtext(fid))` 로 차례) → upsert(새 줄만 `seen_at` = 지금) → 직원마다 10줄). 보낼 대상 · 붙잡을 직원은 늘 세션 표와 함께 고른다(살아 있는 · 제한 없는 앱 세션 — 끊은 세션은 `revoked_at` 으로 바로 빠진다), 끝난 세션의 줄은 5분 정리 `push-cleanup.service.ts`(그 휴대폰을 직원이 건드리지 않고 끝난 세션 — 저절로 끝남 · 다른 곳의 비밀번호 바꾸기 · 앱 갱신 유예 · 토큰 재사용 감지 — 의 휴대폰에만 한 번 session-ended, 끊은 까닭 표는 `push.constants.ts` 의 `SESSION_ENDED_NOTICE` 한 곳(`Record<SessionRevokedReason, boolean>` — 까닭을 더하면 거기서 정해야 컴파일된다) — `DELETE … RETURNING`). 등록이 바뀐 소식은 `PushRegistrationsService.onChanged`(메일 쪽이 듣는다 — 푸시 모듈은 메일을 모른다, 사내 도메인만 `MailEngineModule` 의 `MAIL_SETTINGS` 에서). **보내기** 포트 `PUSH_SENDER`(`push-sender.port.ts` — 알림은 종류 · 개수뿐) · 구현 `configured-push-sender.ts`(env `FCM_SERVICE_ACCOUNT_KEY` — 없으면 보내지 않고 기동은 한다) · `fcm/`: 키 읽기 `fcm-credentials.ts`(env 검사와 같은 함수 — 까닭에 값을 싣지 않는다), OAuth `fcm-auth.ts`(RS256 JWT → `oauth2.googleapis.com/token`, 만료 5분 전까지 기억 · 함께 부르면 한 번), 보내기 `fcm-sender.ts`(`message.fid` — 실패 표는 계약, 시도 3번 · 20초 · 서버 전체 4 · 429 는 `Retry-After` 가 있을 때만 그때 한 번 · 보내기 직전(다시 하기마다) 부른 쪽의 `stillValid` — 그 세션의 줄이 아직 그 FID 인가(`isDeliverable`) · 로그는 종류마다 한 시간에 한 번 · 서버 종료로 끊긴 것은 로그 없이), 메시지 `fcm-message.ts`(순수 함수 한 곳 — 글은 `push-text.ts`), HTTP `fcm-transport.ts`(전역 fetch · 두 주소 고정 · 응답 64KB — 시험만 `forTesting`). 새 의존성 없음. **새 메일** `NewMailPushService`(메일 모듈): 보낼 기기가 있는 사내 직원의 엔진 연결을 붙잡고(`MailEventsService.hold` — SSE 와 같은 연결 · 같은 판정, 신호만 받는다 · 연결 상한에 세지 않는다, 함께 여는 수 4, 실패하면 1 · 5 · 15 · 60분 — 계정 문제는 60분부터), 새 메일 신호 → 같은 직원 30초에 한 번(첫 알림 바로 · 끝에 한 번) → 기기마다 N(`MailProvider.countInboxUnreadSince` — 받은편지함 · 안 읽음 · 초안 아님 · `seen_at` 뒤, 한 요청 8개씩) → N ≥ 1 이면 보낸다 · 받지 않는 기기는 FID 가 그대로일 때만 지운다. 붙잡을 때마다 따라잡기(10분 안 · `seen_at` · `last_pushed_at` 의 **다음 초**부터 — 받은 시각은 초, N 은 `seen_at` 의 초부터 같은 초를 넣는다. 엔진의 `after` 는 그 초를 넣지 않아(0.16.27 — 2026-11-08 확인) 어댑터가 한 초 앞을 넘긴다. 붙잡은 뒤 신호가 먼저 오면 따라잡기는 건너뛰고, 따라잡기 뒤 첫 신호 보내기는 새로 받은 메일이 있는 기기에만 — 붙잡은 직후의 메일 하나에 한 번). 붙잡기가 끝나면(`ended` — 계정 문제 60분 · 그 밖 1분 뒤 다시, 서버 종료가 아닌데 허브가 닫혀도 알린다) 간격 끝 차례도 지운다, 등록이 바뀐 소식은 기다림을 풀고 바로 붙잡아 본다. 같은 직원의 보내기는 `KeyedQueue` 로 차례 |
| 앱 설정 (앱 1단계) | `modules/app-config` | `GET /api/app-config`(로그인 없음 · 기본 요청 수 한도 · `Cache-Control: no-store`) → `{ minSupportedVersion: { android, ios: null }, pushAvailable }`. 최소 판은 코드 상수 `app-config.constants.ts` — 옛 앱을 막을 API 변경과 같은 커밋에서 올린다. `pushAvailable` = `PUSH_SENDER.available` |
| 휴대폰 앱 받기 (사내 배포 — 2026-11-08) | `modules/app-releases` · `modules/ops/app-release-commands.ts` · `shared/utils/download-headers.ts` | 계약 1절 "휴대폰 앱 받기" — 스토어 없이 직접 빌드한 APK 를 로그인한 직원에게만. `GET /api/app-releases/android/latest`(`{ version, sizeBytes, releasedAt }` · `private, no-store` — 없으면 404 `RES_001`, 컨트롤러 필터가 404 를 debug 로) · `…/latest/file`(GET · HEAD · Range — `downloadHeaders()` 가 대용량 첨부 받기(`serveFile`)와 같은 머리글을 만든다, 형식만 APK · 이름 `ARA-SYSTEM-<버전>.apk`). 제한된 세션은 403 `AUTH_010`. **최신 = 가장 늦게 올린 판**(`created_at` — 버전 번호가 아니다, 옛 APK 를 다시 올리면 되돌리기). 표 `app_releases`(마이그레이션 `AddAppReleases`), 파일은 업로드 볼륨 `app-releases/<file_key>.apk`(`app-release-storage.ts` — 서버가 만든 UUID 만 경로로, `.part` 에 쓰고 datasync → rename, 표에 넣지 못하면 지운다, 한 시간 넘은 `.part` 는 다음 올리기가 지운다). 표에 있는데 파일이 없거나 크기가 다르면 404 + error 로그. **올리기는 API 가 없다** — 운영 도구 「휴대폰 앱 올리기」 · 「휴대폰 앱 목록」: `apk-file.ts` 의 `inspectApk`(O_NOFOLLOW · O_NONBLOCK 로 한 번 열어 fstat 일반 파일 · `.apk` · `PK\x03\x04` · 1KB~200MB · SHA-256 — 그 연 파일로 들이고, 들이며 다시 센 SHA-256 이 다르면 `AppReleaseFileChangedError`), 버전 규칙은 앱 알림 등록의 `APP_VERSION_PATTERN` 그대로(`app-release-version.ts`), app-config 의 최소 판보다 낮으면 막고 지금 최신보다 낮거나 같으면 묻는다. 실행 기록 `app-release-upload` |
| 업데이트 내역 (2026-11-10) | `modules/release-notes` · 글 폴더 `backend/release-notes/`(src 밖) | 계약 7절 "업데이트 내역" — 직원이 웹메일 · 앱에서 읽는 게시판, 글은 운영자가 저장소에 **글마다 파일 하나**로 쓴다(올리는 API 없음 · 새 에러 코드 · DB 표 · env 없음). `GET /api/release-notes`(`CursorPaginationQueryDto` + `shared/utils/offset-cursor.ts` — 최근 글부터: 날짜 내림차순 · 같은 날은 뒤 번호 먼저, `total` = 글 수) · `GET /api/release-notes/{id}`(sections · previous(바로 더 옛 글) · next(바로 더 새 글) — 없는 글 · 모양이 틀린 id 모두 404 `RES_001`, `release-note-id.pipe.ts` — 길이 32 + 정규식만 보고 경로로 쓰지 않는다, 읽어 둔 목록에서 찾는다). 보호 라우트(`JwtAuthGuard` · 제한된 세션 403 `AUTH_010`) · `private, no-store`. 해석 `release-note-format.ts`(순수 함수 · 입력 길이에 비례 — 파일 이름 → id · 날짜(있는 날짜인지 · `-2`~`-99`), `# 제목`(첫 글 줄 · 한 번) · `## 머리` · `- 한 줄` · 빈칸 두 개(또는 탭) 이어 붙임 · `<!-- 메모 -->`(여러 줄 · 줄 앞에서 시작 — 앞 빈칸은 되고 글 뒤에 붙이지 않는다), 한도 100 · 40 · 500(UTF-16 — 다듬은 뒤: 앞뒤 빈칸 · 안의 빈칸 하나로 · NFC), 그 밖의 줄 · 하위 목록 · 글 안 `<!--` · 제어 문자는 틀림 — 줄 차례로 앞의 20개 문제(다 읽은 뒤 찾는 앞쪽 줄의 문제도 제자리에 — 메모리는 20개만)). 폴더 `release-notes-folder.ts`(`ReleaseNotesFolder` — **기동 때 한 번**(`onApplicationBootstrap` — 실패해도 던지지 않는다) · 그 뒤 요청 때 readdir + lstat, 이름 · 크기 · 바뀐 시각이 같으면 앞 결과, 함께 온 요청은 한 번 읽기. 글 파일은 `readReleaseNoteFile` 이 `O_RDONLY · O_NOFOLLOW · O_NONBLOCK` 로 한 번 열어 그 핸들로 fstat(일반 파일 · 크기) · 256KB + 1 바이트까지만 읽는다 — `modules/app-releases/apk-file.ts` 와 같은 꼴, Windows 는 두 표시가 0. 틀린 글 · 글이 아닌 파일(다른 이름 · 폴더 · 링크)은 건너뛰고 바뀔 때마다 error 한 번 — 파일 이름 · 줄 · 까닭만(글 내용 없음), 폴더가 없으면 빈 목록 + warn 한 번, UTF-8 만(fatal)). 폴더 자리 `RELEASE_NOTES_DIR`(`__dirname` 기준 — src · dist 모두 `backend/release-notes`, 운영 이미지는 `/app/release-notes` — `backend/Dockerfile` 의 `COPY --from=builder /app/release-notes`), 개발은 바인드 마운트라 다시 띄우지 않아도 새 글이 보인다. 시험은 `ReleaseNotesFolder` 를 임시 폴더로 바꿔 끼운다(useFactory — `AppReleaseStorage` 와 같은 꼴, e2e 의 overrideProvider). **실제 폴더를 엄격하게 읽는 시험** `release-notes.content.spec.ts` — 틀린 글 · 글이 아닌 파일이 있으면 CI 실패(운영자 오타를 잡는다, 고치는 법은 그 폴더의 `README.md`) |
| Rate Limiting | `app.module.ts` 의 `ClientIpThrottlerGuard`(APP_GUARD, `common/guards/client-ip-throttler.guard.ts`) | 초과 시 429 → `REQ_001`. 루트 `.env` 의 `THROTTLE_*` 로 조정. `ThrottlerGuard` 를 상속해 tracker 만 바꿨다 — IP 는 `shared/utils/client-ip.ts` 의 `clientIpKey()`(IPv6 는 /64 로 묶음, 로그인 실패 카운터와 같은 함수) |
| 로깅 | `shared/logger/winston.config.ts` | `console.log` 대신 사용 ([[logging]]) |
| env 검증 | `shared/config/env.validation.ts` | 잘못된 설정이면 **기동 실패**(fail-fast) ([[env-structure]]) |
@@ -447,4 +448,5 @@ dc exec backend npm uninstall <패키지>
새 메일을 알리는 쪽은 엔진 연결을 붙잡아 **신호만** 받는다(`MailEventsService.hold` — 보낸 사람 · 제목을 넘기지 않는다). FID · 서비스 계정 키 · access token · FCM 오류 글은 로그에 남기지 않는다.
**대용량 첨부의 표시**(11단계 — `data-ara-large-files` · `<!--ara-large-files…-->` · `data-ara-quote-start`)는 서버만 넣는다 — 두 정화기가 사용자 HTML 의 `data-ara-*` · 주석을 지우는지
정화를 고칠 때 함께 본다(지우지 못하면 남이 꾸민 덩이가 첨부처럼 보인다). 보내기에 실패해도 링크는 "확실한 실패"(`SendNotStartedException`)에서만 거둔다 — 결과를 모르면 남긴다(메일이 나갔을 수 있다).
- [ ] **업데이트 내역 글**: 글은 `backend/release-notes/` 의 파일로만 더한다(API 없음). 글 형식을 바꾸면 `release-note-format.ts` · 계약 7절 · 그 폴더의 `README.md` · 보드 C 를 함께 고치고, `release-notes.content.spec.ts` 가 실제 폴더를 그대로 통과하는지 본다.
- [ ] **env**: DB/Redis/CORS/포트는 루트 `.env` 에서 주입된다. 백엔드 고유 시크릿(`JWT_SECRET` 등)만 `backend/.env.{env}` 에 작성(compose `env_file` 로 로드).
+9 -5
View File
@@ -42,9 +42,10 @@ description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API
| 화면 이동 규칙 | `src/composables/useMailNavigation.ts` | 이전/다음은 replace, "목록으로"는 뒤로 가기 또는 replace (기록이 쌓이지 않게) |
| 발송 키 규칙 | `src/shared/utils/idempotency-key.ts` 의 `shouldRenewSendKey()` | `Idempotency-Key` 는 성공과 `MAIL_005` 때만 바꾼다 — 그 밖의 실패 뒤 다시 보내기는 같은 키 (계약 2절 "발송 본문"). UUID 만들기는 `shared/utils/uuid.ts` 하나(필터 id 와 공용 — `crypto.randomUUID` 가 없으면 대체 경로) |
| 첨부 사전 검사 | `src/shared/utils/mail-attachment.ts` 의 `precheckAttachment()` | 형식·파일당·합계 한도(`mail.store` 의 `attachmentMaxBytes`·`attachmentTotalMaxBytes`, 서버 값이 없으면 대체값). 넘는 파일은 올리지 않는다 |
| 메일 본문 편집기 | `src/components/mail/editor/` — `MailEditor.vue` (쓰기 화면이 `defineAsyncComponent` 로 불러온다) | TipTap core (MIT 무료 패키지만, `@tiptap/*` 는 모두 같은 버전으로 고정). 툴바 배치·좁은 폭에서 접는 순서·단축키는 `docs/design/webmail/README.md` "메일 쓰기". 보낼 모양은 `mail-serialize.ts` — 서식 본문 `bodyHtml`(기본 글꼴 div, 인라인 style) + 텍스트 대체 본문 `body` (계약 2절 "서식 본문"). 답장·전달 초안 한도는 `fit-draft.ts`(처음 맞출 때 내 글 몫 5%를 남기고, 보내기 직전 `getContentForSend()` 가 다시 재서 인용을 더 줄인다 — 내 글만으로 넘으면 보내지 않고 칸 아래에 알린다), 붙여 넣거나 끌어다 놓은 파일은 첨부로(`shared/utils/editor-files.ts`, 화면 어디에 놓아도 — `composables/useWindowFileDrop.ts`). **읽어 들이는 style 값은 서버 정화와 같은 형식만** 받는다 — 규칙 `shared/utils/outgoing-html-rules.ts`(짝: backend `sanitize-outgoing-html.ts`, 같은 표본 표 `test-utils/outgoing-style-samples.ts` — 한쪽을 고치면 둘 다), 적용 `editor/mail-format-extensions.ts`. 편집기 청크를 못 받으면 `ComposeEditorLoadError.vue`("다시 시도"), 단축키 표기는 플랫폼별(`shared/utils/keyboard-shortcut.ts`). **그림은 서명 그림만** 받는다 — 노드 `editor/signature-image.ts`(쓰기 본문·서명 칸 공용, 규칙은 `outgoing-html-rules.ts` 의 그림 부분 — 짝: backend `html/signature-images.ts`·`keepSignatureImage`, 표본 `IMAGE_SAMPLES`). 메뉴(`editor/EditorMenu.vue` + `EditorPopup.vue` — 이동 ▾ · 메일함 ⋯ · 읽기 화면 ⋯ 도 같은 부품)의 꺼진 항목은 `disabled` 가 아니라 `aria-disabled` — 화살표로 가서 초점은 받되 고를 수 없다(WAI-ARIA 메뉴). 누를 수 없는 까닭은 항목의 `note`(이름 아래 작은 글, `aria-describedby`). 메뉴 · 팝오버의 좌우는 바라는 끝(`align`)으로 그린 뒤 보이는 영역 밖으로 넘치면 덜 넘치는 반대 끝에 맞춘다(15단계 — `EditorPopup` 이 그린 직후 · 초점을 주기 전에 스스로 잰다: `composables/usePopupPlacement.ts` 의 `measurePopupAlign` · 판단 `shared/utils/popup-placement.ts` 의 `popupAlignFor`). 항목은 들여쓰기(`depth` — 10 · 28 · 44 · 56)와 따로 읽힐 이름(`ariaLabel`)을 받는다. **서명 자리**(16단계 — `editor/mail-signature-block.ts`의 `mailSignature` 블록 노드): 보낼 때 · 저장할 때 빈 문단 뒤의 **속성 없는 `<div>`**(앱과 같은 경계 — 계약 2절 "앱이 만드는 모양")로 나가고, 다시 열 때 "맨 위 단계에서 빈 문단 바로 뒤의 속성 없는 div 이고 글이나 그림이 있다"로 되찾는다(원문 인용 안 · 꾸민 · 빈 div 는 아니다). 서명 자리는 하나 — 붙여 넣기로 하나 더 생기면 처음 것만 두고 푼다. **서명 자리 앞에는 늘 빈 줄** — 그 빈 줄에 쓰거나 지우면 다시 넣는다(같은 appendTransaction — 한글 조합 중(`composition` 표시)에는 미루고, 조합이 끝나면 플러그인 view 가 `compositionend` 뒤 ProseMirror 가 조합을 마무리한 다음 차례(50ms)에 표시만 단 빈 거래로 깨운다 — 그사이 새 조합이 시작됐으면 그 조합이 끝날 때. 화면에 붙인 편집기에서만 돈다 · 시험은 `editor.mount` + `CompositionEvent` + 가짜 setTimeout), 보내고 저장하는 모양은 `withSignatureBoundary` 로 한 번 더(`MailEditor` 의 `contentOf` · 저장한 뒤 견줄 `markSaved` 의 settled) — 리뷰 M3. |
| 메일 본문 편집기 | `src/components/mail/editor/` — `MailEditor.vue` (쓰기 화면이 `defineAsyncComponent` 로 불러온다) | TipTap core (MIT 무료 패키지만, `@tiptap/*` 는 모두 같은 버전으로 고정). 툴바 배치·좁은 폭에서 접는 순서·단축키는 `docs/design/webmail/README.md` "메일 쓰기". 보낼 모양은 `mail-serialize.ts` — 서식 본문 `bodyHtml`(기본 글꼴 div, 인라인 style) + 텍스트 대체 본문 `body` (계약 2절 "서식 본문"). 답장·전달 초안 한도는 `fit-draft.ts`(처음 맞출 때 내 글 몫 5%를 남기고, 보내기 직전 `getContentForSend()` 가 다시 재서 인용을 더 줄인다 — 내 글만으로 넘으면 보내지 않고 칸 아래에 알린다), 붙여 넣거나 끌어다 놓은 파일은 첨부로(`shared/utils/editor-files.ts`, 화면 어디에 놓아도 — `composables/useWindowFileDrop.ts`). **읽어 들이는 style 값은 서버 정화와 같은 형식만** 받는다 — 규칙 `shared/utils/outgoing-html-rules.ts`(짝: backend `sanitize-outgoing-html.ts`, 같은 표본 표 `test-utils/outgoing-style-samples.ts` — 한쪽을 고치면 둘 다), 적용 `editor/mail-format-extensions.ts`. 편집기 청크를 못 받으면 `ComposeEditorLoadError.vue`("다시 시도"), 단축키 표기는 플랫폼별(`shared/utils/keyboard-shortcut.ts`). **그림은 서명 그림만** 받는다 — 노드 `editor/signature-image.ts`(쓰기 본문·서명 칸 공용, 규칙은 `outgoing-html-rules.ts` 의 그림 부분 — 짝: backend `html/signature-images.ts`·`keepSignatureImage`, 표본 `IMAGE_SAMPLES`). 메뉴(`editor/EditorMenu.vue` + `EditorPopup.vue` — 이동 ▾ · 메일함 ⋯ · 읽기 화면 ⋯ 도 같은 부품)의 꺼진 항목은 `disabled` 가 아니라 `aria-disabled` — 화살표로 가서 초점은 받되 고를 수 없다(WAI-ARIA 메뉴). 누를 수 없는 까닭은 항목의 `note`(이름 아래 작은 글, `aria-describedby`). 메뉴 · 팝오버의 좌우는 바라는 끝(`align`)으로 그린 뒤 보이는 영역 밖으로 넘치면 덜 넘치는 반대 끝에 맞춘다(15단계 — `EditorPopup` 이 그린 직후 · 초점을 주기 전에 스스로 잰다: `composables/usePopupPlacement.ts` 의 `measurePopupAlign` · 판단 `shared/utils/popup-placement.ts` 의 `popupAlignFor`). 항목은 들여쓰기(`depth` — 10 · 28 · 44 · 56)와 따로 읽힐 이름(`ariaLabel`)을 받는다. **서명 자리**(16단계 — `editor/mail-signature-block.ts`의 `mailSignature` 블록 노드): 보낼 때 · 저장할 때 빈 문단 뒤의 **속성 없는 `<div>`**(앱과 같은 경계 — 계약 2절 "앱이 만드는 모양")로 나가고, 다시 열 때 "맨 위 단계에서 빈 문단 바로 뒤의 속성 없는 div 이고 글이나 그림이 있다"로 되찾는다(원문 인용 안 · 꾸민 · 빈 div 는 아니다). 서명 자리는 하나 — 붙여 넣기로 하나 더 생기면 처음 것만 두고 푼다. **서명 자리 앞에는 늘 빈 줄** — 그 빈 줄에 쓰거나 지우면 다시 넣는다(같은 appendTransaction — 한글 조합 중(`composition` 표시)에는 미루고, 조합이 끝나면 플러그인 view 가 `compositionend` 뒤 ProseMirror 가 조합을 마무리한 다음 차례(50ms)에 표시만 단 빈 거래로 깨운다 — 그사이 새 조합이 시작됐으면 그 조합이 끝날 때. 화면에 붙인 편집기에서만 돈다 · 시험은 `editor.mount` + `CompositionEvent` + 가짜 setTimeout), 보내고 저장하는 모양은 `withSignatureBoundary` 로 한 번 더(`MailEditor` 의 `contentOf` · 저장한 뒤 견줄 `markSaved` 의 settled) — 리뷰 M3. 툴바는 z-index 2 이고 메뉴 · 팝오버를 연 동안만 4(`editor-toolbar--popup-open` — 쓰기 화면 아래에 붙은 [보내기] 줄(3) 위로). 넓은 화면 편집 영역 최소 200 은 「쓰기 화면 스크롤」 행 |
| 임시 저장 | `src/composables/useDraftSaving.ts` · `components/mail/ComposeDraftStatus.vue` · `composables/useComposeDraftLink.ts`(주소 맞추기 · 옛 id 기록 · 보내지 못한 메일을 저장본으로) · `composables/useDraftTrash.ts`(휴지통 · 되돌리기) | 3초 멈춤 · 쉬지 않고 쓰면 30초 · 10초 간격 · 한 번에 하나(계약 2절 "임시 저장", 상수 `shared/constants/mail.ts` 의 `DRAFT_*`). `MAIL_008`·PUT 404 는 막지 않고 POST 로 새로. 찾지 못한 첨부가 남았거나 본문이 한도를 넘는 동안은 저장하지 않고, 실을 수 없는 첨부가 든 남의 저장본은 첫 저장을 POST 로(원본 보존). 보내기·버리기 전에는 `suspend`+`settle` — 보내기는 settle 뒤 첨부 상태를 다시 본다. 저장할 때마다 주소를 `?draft=<새 id>` 로 맞추고(떠나기 전 저장 제외), 옛 id 로 돌아오면 `mail.store` 의 `latestDraftId()` 로 마지막 저장본을 연다. 쓰기 화면 휴지통은 DELETE 가 아니라 PATCH(휴지통으로 이동) — 이미 휴지통인 메일은 DELETE 가 영구 삭제한다 |
| 받는 사람 자동 완성 | `src/composables/useRecipientSuggestions.ts` · `shared/utils/recipient-match.ts` · `components/mail/RecipientField.vue` · `RecipientSuggestionList.vue` | 검색어는 input · compositionupdate 의 `target.value`(한글 조합 중 글자 포함)로 따로 두고, 칩 확정은 v-model. Enter 등의 조합 판정은 `shared/utils/keyboard-shortcut.ts` 의 `isImeComposing()`(`isComposing` 또는 keyCode 229 — Safari · macOS) — 새 입력 칸의 Enter 처리도 이것을 쓴다. 쓰기 화면을 열 때 `limit=100` 한 번, 모자랄 때만 250ms 뒤 `q` 로(8단계부터 초성 · 조합 중 글자도 묻는다 — 서버의 q 가 같은 맞추기다: 짝 backend `modules/mail/recipient-match.ts`, 같은 표본 표 `test-utils/recipient-match-samples.ts` — 한쪽을 고치면 둘 다. 늦은 응답은 버린다). 초성 검색·맞은 글자 강조는 `recipient-match.ts`(강조는 `<strong>` 조각 — v-html 없음). combobox 접근성(aria-activedescendant · listbox · 조합 중 Enter 무시), 완전한 주소면 미리 고르지 않는다 |
| 쓰기 화면 스크롤 (2026-11-10 사용자 보고) | `src/components/mail/ComposeFooter.vue`(아래에 붙는 [보내기] 줄) · `RecipientField.vue`(칩 · 입력 칸 안 스크롤) · `pages/mail/ComposePage.vue`(넓은 화면 편집 영역 · 위치) · `components/mail/editor/MailEditor.vue` · `EditorToolbar.vue`(`editor-toolbar--popup-open`) · `composables/useFocusAboveFooter.ts` · 토큰 `--size-recipient-chips-max`(96) · `--size-editor-body-min-fit`(200) · 시험 `pages/mail/ComposePage.scroll.spec.ts`(원본 style 읽기 + 초점) | 받는 사람이 많으면 바깥 · 본문 이중 스크롤에 [보내기] 줄이 화면 밖으로 숨었다. **[보내기] 줄은 스크롤하는 칸(넓은 화면 `.compose` · 좁은 화면 문서)의 아래에 붙는다**(`position: sticky; bottom: 0` + 흰 바탕 — 모양 그대로). **겹침 차례: 툴바 2 < [보내기] 줄 3 < 받는 사람 추천 목록 4 · 메뉴를 연 툴바 4**(`.editor-toolbar--popup-open` — 툴바는 줄 밑으로 지나가고 툴바의 메뉴는 줄 위에 뜬다). 줄의 메뉴([보내기 ▾] · [서명 ▾])는 위로 연다(`usePopupPlacement`). **받는 사람 · 참조 · 숨은참조의 칩 · 입력 칸(`.recipient-field__control`)은 96(+ 위 여백 4)을 넘으면 그 칸 안에서 스크롤** — 맨 위 칩 세 줄 · 맨 아래 칩 두 줄 + 입력 줄이 줄 사이에서 끊긴다(반쯤 잘린 칩 없음). 위 · 좌우 안쪽 여백 4 를 바깥 여백으로 당겨 자리 · 줄바꿈은 그대로 두고 칩 ✕ 의 초점 고리가 잘리지 않게(아래는 당기지 않는다 — 줄의 초점 선을 덮지 않게). 초점이 오거나 · 칩이 늘거나(`flush: 'post'`) · 글을 치면 칸을 맨 아래로, 칩 ✕ 는 `focus({ preventScroll: true })` 로 자리를 그대로(가운데 칩을 이어 지운다). 추천 목록은 그 칸 밖(`.recipient-field` 기준)에 뜬다. **넓은 화면(70rem 이상)은 편집 영역이 300 에서 200 까지 먼저 줄어** 보통 높이(1366×768 · 1440×900)에서는 쓰기 칸 전체가 화면 안에 든다(좁은 화면은 300 그대로). **`.compose` 는 `position: relative`** — 안의 `.visually-hidden`(파일 고르기 칸 · 단추 줄의 낭독 글)이 화면 전체를 기준으로 놓여 넓은 화면에서 문서까지 스크롤했다(168px). **[보내기] 줄에 가린 칸으로 온 초점은 올린다**(`useFocusAboveFooter` — form 의 `@focusin` · `@pointerdown.capture`: 다음 그림에 재어 가린 만큼(+8) 스크롤 상자를 올린다, 쓰기 칸의 줄은 `.compose-field` 전체, 누른 칸(1초 안 · 그 줄 안)은 그대로 — 누른 채 움직이면 글이 골라진다, WCAG 2.4.11). 시험: vitest 는 CSS 파일을 비운다(`tokens.css?raw` 도 빈 글) — 토큰 값은 브라우저로 보고 시험은 컴포넌트가 토큰을 쓰는지만 — 디자인 README "메일 쓰기 › 받는 사람이 많을 때 · 낮은 화면" |
| 받는 사람 자동 완성 | `src/composables/useRecipientSuggestions.ts` · `shared/utils/recipient-match.ts` · `components/mail/RecipientField.vue` · `RecipientSuggestionList.vue` | 검색어는 input · compositionupdate 의 `target.value`(한글 조합 중 글자 포함)로 따로 두고, 칩 확정은 v-model. Enter 등의 조합 판정은 `shared/utils/keyboard-shortcut.ts` 의 `isImeComposing()`(`isComposing` 또는 keyCode 229 — Safari · macOS) — 새 입력 칸의 Enter 처리도 이것을 쓴다. 쓰기 화면을 열 때 `limit=100` 한 번, 모자랄 때만 250ms 뒤 `q` 로(8단계부터 초성 · 조합 중 글자도 묻는다 — 서버의 q 가 같은 맞추기다: 짝 backend `modules/mail/recipient-match.ts`, 같은 표본 표 `test-utils/recipient-match-samples.ts` — 한쪽을 고치면 둘 다. 늦은 응답은 버린다). 초성 검색·맞은 글자 강조는 `recipient-match.ts`(강조는 `<strong>` 조각 — v-html 없음). combobox 접근성(aria-activedescendant · listbox · 조합 중 Enter 무시), 완전한 주소면 미리 고르지 않는다 칩이 세 줄을 넘으면 칩 · 입력 칸 안에서 스크롤 — 초점 · 칩 늘어남 · 입력 때 맨 아래로, 칩 ✕ 는 자리 그대로(「쓰기 화면 스크롤」 행) |
| 메일 서명 (16단계 — 여러 서명) | `src/stores/mail-signature.store.ts` · API `composables/useMailSignatures.ts` · 타입 `shared/types/mail-signature.ts` · `components/mail/editor/signature/`(`SignatureSettings` 화면 · `SignatureList` · `SignatureDefaultsField` · `SignatureForm` · `SignatureEmpty` · `SignatureDeleteDialog` · 공용 편집기 `SignatureEditor` + `SignatureToolbar`) · `shared/utils/mail-signature.ts` · `shared/constants/mail-signature.ts` · `pages/settings/MailSignaturePage.vue` · 쓰기 화면 [서명 ▾] `components/mail/ComposeSignatureMenu.vue`(ComposeFooter 의 `signature` 슬롯) · 서명 자리 `components/mail/editor/mail-signature-block.ts` | 서명은 직원마다 10개(만든 차례)와 기본 둘(새 메일 · 답장과 전달 — `GET · PUT /mail-signature-defaults`, null = 넣지 않음) — 계약 2절 "서명". 스토어는 목록과 기본 둘을 함께 받는다(`ensureLoaded` — 메일 화면에 들어올 때 · 쓰기 화면이 열 때, 실패는 알리지 않고 서명 없이). 만들기 · 고치기 · 지우기는 받아 둔 목록을 그 자리에서 고치고(첫 서명은 기본 둘 — 서버와 같게, 지운 서명의 기본은 null), 기본 고르기(`chooseDefault`)는 화면을 먼저 바꾸고 **저장은 한 번에 하나**(보내기 · 읽기 설정과 같은 결 — 끝내 실패하면 서버가 확인한 값으로). 저장 거절(`VAL_001`)은 `refresh()`(조용히 — 그사이 바꿨으면 버림)로 다른 창의 같은 이름을 가른다. 설정 화면은 들어올 때마다 `load` 하고 TipTap 청크(`SignatureSettings`)를 지연 로딩한다 — 목록 208 · 고치는 칸, 칸이 640 보다 좁으면 목록을 위로(컨테이너 쿼리 `signature-settings`). 고른 서명마다 `SignatureForm` 을 key 로 새로 만들되 새 서명을 처음 저장한 것은 같은 칸이다. 저장하지 않은 채 다른 서명 · [새 서명] · [취소]는 `LeaveConfirmDialog`(`signature-switch`), 다른 화면은 `useLeaveConfirm`(`signature-leave`). 이름 검사 `signatureNameProblem`(앞뒤 공백 · 영문 대소문자 무시 — 서버와 같다). 미리 보기는 편집기 스키마의 `DOMSerializer` 로 고른 서명을 늘 그린다. 그림 3장은 서명 칸 안의 그림 수로(한 사람 보관 20장은 서버가 센다 — 올리기 `VAL_001` 은 알림). **쓰기 화면**: 새 메일은 새 메일 기본을 끝에, 답장 · 전달은 답장과 전달 기본을 인용 위에 서명 자리(`signatureBlockHtml` — `data-mail-signature` 표시 div)로 넣고, 이어 쓰기는 다시 넣지 않는다. [서명 ▾](임시 저장 다음 · 44 · 편집기 메뉴 220 · `menuitemradio` ✓ · 키보드로 열면 첫 항목 — `EditorMenu` 의 `initialFocus`)는 `MailEditorHandle` 의 `replaceSignature`(그 자리만 · 실행 취소 됨 · 넣지 않음은 앞 빈 줄까지 · 없으면 처음 넣는 자리) · `signaturePlace` · `signatureFingerprint`(글 · 그림으로 견줘 ✓ — 고쳤으면 없음, 단추 이름 "서명: 대외" · "서명")를 쓰고 바꾼 뒤 role=status 에만 "서명을 ‘사내’로 바꿨습니다."(조사 `directionParticle`). 서명 관리는 설정 › 메일 › 서명(나가기 규칙대로) — 디자인 README "16단계" |
| 메일 스토어 구성 | `src/stores/mail.store.ts`(조립 · 공개 API) + `src/stores/mail/` — `mailboxes.ts` · `list.ts` · `selection.ts` · `message.ts` · `bulk.ts` · `mailbox-admin.ts`(4단계 메일함 관리) · `conversation.ts`(14단계 같은 대화 칸) | Pinia 스토어는 `useMailStore` 하나이고 기능별 setup 모듈을 조립한다(스토어를 여러 개로 나누지 않는다 — 서로 부르는 고리가 생긴다). 목록 출처는 `shared/utils/mail-list-source.ts`(메일함 · 검색 · 대화로 묶은 메일함 `conversations` — 14단계). **목록에 든 메일인지 볼 때는 `messages` 대신 줄 기준 도우미**(`rowCount` · `rowIndexOfMessage` · `rowMessageIdAt` · `isListedMessage` · `listedSummaries`)를 쓴다 — 대화 목록에서는 `messages` 가 비어 있다. 목록 요청은 출처가 바뀌면 `listAbort`(AbortController)로 끊는다(끊긴 요청은 알리지 않는다). **늦게 온 응답 버리기**: 목록 맞추기는 이 탭의 목록 변경 세대(`localGeneration` — 읽음 · 중요 · 빼기 · 옮기기마다 오른다)가 그사이 바뀌었으면 응답을 버리고 한 번 더 맞춘다. 여러 통 처리 · 한 통 삭제는 시작할 때의 출처 순번(`sourceSeq`)과 다르면 목록을 건드리지 않고 개수만 맞춘다 |
| 새 메일 확인 · 탭 제목 | `src/composables/useMailStatePolling.ts`(메일 화면 `MailLayout` 이 시작) · `useListArrivalNotice.ts` + `components/mail/MailNewNotice.vue` · `useMailTabTitle.ts` + `shared/utils/document-title.ts` | 화면이 보이는 동안 60초마다 `GET /mail-state`, 탭으로 돌아오면 10초 지났을 때 바로, 한 번에 하나, 실패는 `silent`. 바뀌면 `syncCounts()` + 보이는 목록 맞추기(불러온 만큼 · 최대 100통, 검색 결과는 건너뜀). 목록을 내려 본 채면 "새 메일 N통 · 맨 위로" 줄. 받은편지함 안 읽음이 있으면 탭 제목 앞 `(N) ` (메일 라우트에서만) — 계약 2절 "새 메일 확인" |
@@ -55,15 +56,15 @@ description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API
| 메일 검색 — 14단계 (조건 · 강조 · 준비 중) | `src/components/mail/MailSearchConditions.vue`(조건 패널 — 칸 `MailSearchConditionFields.vue` · 기간 `MailSearchPeriodField.vue` · [조건] `MailSearchConditionsToggle.vue`, 고치는 값 `composables/useSearchConditionsDraft.ts` · 열고 닫기 `useSearchConditionsPopup.ts`) · `MailSearchConditionLine.vue`(조건 줄) · `MailSearchNotices.vue`(준비 중 · 한 글자 안내) · `MailSearchMore.vue`([더 찾기]) · `HighlightedText.vue` · `shared/utils/mail-search.ts`(조건 ↔ 주소 ↔ API · 기간 · 조건 줄 글) · `search-highlight.ts` · `search-match.ts` · `shared/constants/mail-search.ts` | 조건 이름 · 화면 주소(`since` · `until` 은 브라우저 시간대 날짜 · 양 끝 포함, 첨부 `attachment=1`) · 이어 찾기(`MAIL_SEARCH_FILL_MAX_REQUESTS` 5번)는 계약 2절 "검색 — 14단계"의 클라이언트 줄. **`search-match.ts` 는 backend `shared/utils/search-query.ts` · `search-text.ts` · `subject-base.ts` 와 짝** — 같은 표본 표 `test-utils/search-text-samples.ts`(backend `test/search-text-samples.ts` 와 행 · 차례까지 같다, 보이지 않는 글자는 `\u` 로 적는다 — 한쪽을 고치면 둘 다). 맞은 말은 `HighlightedText` 가 글 조각 + `<mark>` 로 그린다(v-html 없음 — 위치가 글 밖 · 거꾸로 · 정수가 아니면 버리고, 서로게이트 쌍을 가르지 않는다). **강조가 든 응답은 보이기만 한다** — `openMessage(id, highlight)` 는 강조 쿼리가 다르면 다시 받고, 답장 · 전달 · 인쇄 · 예약 · 대용량 첨부 다시 받기는 강조 쿼리 없이 받는다. 손으로 고친 주소도 패널과 같은 검사(8낱말 넘으면 묻지 않고 "찾을 말이 너무 많습니다"). 강조 노랑은 `--color-search-mark` — 본문 틀(`mail-html.ts`)의 같은 값과 짝. 미리 보기 줄(회색 글) 안의 강조만 글자를 `--color-text-secondary` 로 진하게(노랑 위 대비 — `MessageListItem.vue`). 패널의 [조건 지우기]는 칸만 비운다 — 패널은 열린 채 · 찾지 않음, 걸린 조건은 [찾기] · 조건 줄의 [조건 지우기]가 뺀다(2026-11-07) |
| 같은 대화 (14단계) | `src/components/mail/MessageConversation.vue`(읽기 화면 첨부 아래 칸) · `ConversationMessageRow.vue`(줄) · `ConversationMessagePanel.vue`(펼친 안) · `stores/mail/conversation.ts` · `composables/useConversationSync.ts`(언제 받을지 — 시계 주입) · `shared/utils/mail-conversation.ts`(이름 자리 · 쪽 맞추기 · 갈 곳) · `shared/constants/mail-conversation.ts` · `shared/utils/format.ts` 의 `formatConversationTime` | 받기 · 다시 받기는 계약 2절 "대화 — 14단계"의 클라이언트 줄(100통 · 이어 묻기 5번 · [더 보기], 실패는 칸 안 안내 — 404 면 칸 없음). **다시 받기 판정은 `mail.store` 의 `mailboxTotalsKey`**(메일함마다 `id:totalCount` — 바뀔 때만 `requestConversationRefresh()`, MailLayout 의 바뀜 소식에서는 부르지 않는다 — 읽음 · 중요 표시만 바뀐 소식으로 대화를 다시 받지 않게 — 칸의 안 읽은 줄은 목록처럼 굵게 그리고, 이 탭이 바꾼 읽음은 `list.ts` 의 `watchLocalReads` 로 바로 적는다: 받는 동안 바꾼 것은 `conversation.ts` 의 `withLocalReads` 가 받은 값보다 앞세우고, 다른 탭의 읽음은 다음 받기 때 — 2026-11-07), 간격은 받기가 끝난 때(`conversationBusy` 가 꺼질 때)부터 10초. 펼친 본문은 `MailBodyFrame` · 강조 쿼리 없이, 안 읽은 메일은 `markRead`(`Promise<boolean>` — 같은 목록의 `pendingFlags` 에 같은 읽음이 있으면 건너뛴다)로. `closeMessage` 가 칸도 비운다. 초점은 누른 단추가 사라지면 줄의 펼침 단추 → 새 [다시 시도] → 칸 h3(`tabindex="-1"`) → 칸이 없어지면 읽기 화면에 `settled`. 상태 영역은 늘 두고 글만 바꾼다(다시 실패해도 읽게). [이 메일만 열기]는 분할 보기 push · 목록 보기 replace(layout prop). 줄 차례는 `orderHeldLast`(붙잡힌 메일은 끝 — 보낼 시각 차례), 미리 보기는 `previewWithoutOwnQuote`(우리 원문 인용 머리부터 잘라 냄 — `mail-conversation.ts`), 펼친 줄의 첨부 수 안내는 `ConversationMessagePanel`. 15단계: 칸 머리 오른쪽 [모두 펼치기] · [모두 접기](36 — `MAIL_CONVERSATION_TEXT.expandAll` · `collapseAll`, 판정 `conversationAllExpanded` — 하나씩 다 펼쳐도 [모두 접기]). 모두 펼친 줄의 본문은 화면에 들어온 줄부터 하나씩(`composables/useConversationPanelVisibility.ts` — IntersectionObserver 하나로 `[data-conversation-panel]` 을 보고 스토어의 `noteConversationPanelVisible` 에 알린다 · 차례 `detailQueue`, 차례 전에 화면 밖으로 나간 줄은 건너뛴다), 읽음은 본문이 화면에 보였을 때만(`lazyRows` — 손으로 펼친 줄은 지금처럼 받는 대로). 펼친 줄 머리 [답장](`data-conversation-head-reply` — 머리 틀 `.conversation-row__head` 안에 펼침 단추와 나란히, 임시 저장본 · 예약 · 지워진 메일엔 없다) |
| 대화로 묶어 보기 (14단계) | `src/components/mail/MailConversationToggle.vue`(목록 머리 [대화로 묶기]) · `ConversationListItem.vue`(분할 보기 줄) · `ConversationTableRow.vue`(목록 보기 표 줄) · `composables/useConversationRows.ts` · `shared/utils/mail-conversation-list.ts`(응답 맞추기 · 할 일 id · 참여자 · 머리 글 · 표시 적용 · 맞추기 병합 · 삭제 뒤 갈 곳) · `stores/mail/list.ts`(출처 `conversations`) · 설정 `stores/mail-settings.store.ts` 의 `groupByConversation`(+ 설정 › 보내기 · 읽기 한 줄) · 글 `shared/constants/mail-conversation.ts` 의 `MAIL_CONVERSATION_LIST_TEXT` | 받기 · 맞추기 · 줄에 하는 일은 계약 2절 "대화 — 14단계"의 클라이언트 줄(20줄 · 맞추기는 불러온 줄 수만큼 최대 100줄 `silent` · 할 일 = `messageIds` − 붙잡힌 메일 · 줄 전체 표시는 모두 바꿨을 때만 · 옮기기 · 삭제 뒤 한 번 다시 받기). **첫 진입 짐작**은 localStorage `groupByConversationHintKey(주소)` — 설정을 받기 전 · 받지 못했으면 이 값, 받으면 서버 값, 읽기 · 쓰기가 던져도 끔. 로그아웃 · 세션 만료에 설정 스토어를 비워도 화면이 보는 값은 그대로 둔다(뒤집히면 watcher 가 떠나는 화면의 목록을 다시 부른다 — 2026-11-06 리뷰). **목록을 부르는 watcher 는 `authStore.canUseApp` 이 false 면 부르지 않는다**(소스에도 넣는다). 붙잡힌 메일이 든 줄은 고르기 칸 · 별이 `aria-disabled` + 까닭(`SEND_LATER_TEXT.heldNotSelectable`). 목록 줄의 화면 낭독 글("안 읽음, " · "중요 표시" · "첨부 있음")은 `shared/constants/mail.ts` 의 `MAIL_LIST_ROW_TEXT` — 메일 줄은 아직 직접 적은 글이라 함께 고친다. 읽기 화면 [이전] · [다음] · 위치는 대화 단위(`ListReadNav.vue`), 한 통 삭제 뒤 갈 곳은 `conversationNeighborAfterRemoval`. 분할 보기 머리의 줄인 개수 글 `listHeadShortCountText`(전체 글은 title · 숨긴 글) · 도구 줄 `selectedShort`(숨긴 글 "대화 N개 선택됨") · 표 머리 "참여자"(`participantsColumn`) · 줄 알림 `announceMove(…, { from })` → `MAIL_BULK_TEXT.movedFrom`("{메일함}에 있는 메일 N통을 …"). 영구 삭제는 `MailPurgeDialog` 의 `from` + `announcePurge(…, { from })` → `purgeTitleFrom` · `purgedFrom`(값은 `useBulkMailActions` 의 `conversationFrom` — 묶지 않은 목록 · 읽기 화면은 null). **설정 다시 받기** `composables/useMailSettingsRefresh.ts`(MailLayout — 탭이 보일 때 60초 `MAIL_SETTINGS_REFRESH_AFTER_MS` · `canUseApp`) + 스토어 `refresh()`(silent · 받는 동안 loading 으로 바꾸지 않음 · 저장 중이면 하지 않음 · 받는 사이 고른 것(`choiceSeq`) · 다시 받기(`loadSeq`) · 세션이 바뀌면 버림) · `fetchSeq` — 디자인 README "14단계 구현하며 정한 보완 — 목록 대화로 묶어 보기" |
| 여러 통 처리 · 삭제 | `src/components/mail/MailCheckbox.vue` · `MailSelectionToolbar.vue`(+ `MailBulkButton` · `MailBulkMoveButton`) · `MailPurgeDialog.vue` · `MessageDeleteButton.vue` · `composables/useBulkMailActions.ts` · `useMessageRemoval.ts` · `useMailMoveFeedback.ts` · `shared/utils/mail-selection.ts` · `mail-bulk.ts` | 100통씩 차례로. 읽음 · 중요는 먼저 바꾸고 `failedIds` 만 되돌리고, 이동 · 삭제는 응답의 id 만 뺀다. **웹은 `DELETE /api/messages/{id}` 를 쓰지 않는다** — 휴지통 밖 삭제는 `PATCH` 이동(+ 되돌리기), 휴지통에서는 확인 대화상자(처음 초점 「취소」) 뒤 `POST /message-deletions`. 읽기 화면도 그 메일의 `mailboxIds` 로 같은 규칙을 탄다. 도구 줄은 role="toolbar" 로빙 tabindex(바쁜 · 꺼진 단추는 건너뛴다). [되돌리기] 뒤 알림은 한 메일함이면 그 이름(`restoredMessageText(…, mailboxName)` — "메일 N통을 받은편지함으로 되돌렸습니다.") — 계약 2절 "여러 통 한꺼번에". [이동 ▾]의 옮길 곳 · 스팸 표시 · 끌어다 놓기는 아래 「메일 옮기기 · 끌어다 놓기 · 좁은 읽기 칸 (15단계)」 행 |
| 설정 › 비밀번호 · 부재중 (4단계) | `src/pages/settings/AccountPasswordPage.vue` · `MailVacationPage.vue` · `components/settings/`(`SettingsPageFrame` 공용 틀 — 머리 오른쪽 단추는 `actions` 슬롯 · `PasswordChangeForm` · `PasswordField` · `Vacation*`) · `stores/mail-vacation.store.ts` · `shared/utils/password-change.ts` · `mail-vacation.ts` · 메뉴 `shared/constants/settings-menu.ts` | 메뉴는 묶음(계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 · 필터(6단계) · 알림(5단계)), 라우트 이름은 `SETTINGS_ROUTES` 하나. 비밀번호는 서버와 같은 규칙을 먼저 보고, 서버 거절(`AUTH_005` · `VAL_001`)은 칸 아래에만(알림 없음) — 성공하면 세 칸을 비운다. 부재중은 날짜만 고르고 브라우저 시간대의 시작일 0시 · 끝나는 날 다음 날 0시를 UTC 로 보낸다(변환은 `mail-vacation.ts` 한 곳 — 되읽을 때는 1ms 앞 날짜). 켤 때만 검사하고 꺼 둔 채로는 빈 값도 저장한다(길이 150 · 600 도 켤 때만 — 꺼 둔 채로는 4단계 한도까지, 끄기는 늘 저장되게 — 16단계부터 내용은 편집기, 아래 「부재중 서식 본문 · 회사 밖 응답 (16단계)」 행). 부재중 상태는 메일 화면에 들어올 때 받고, 패널 안내 `components/mail/MailVacationNotice.vue` 가 1분마다 다시 계산한다(`useClock` · `VACATION_NOTICE_REFRESH_MS`). 저장 응답은 세션 세대(`authStore.generation`)가 바뀌었으면 쓰지 않는다. 저장한 값이 켜짐인데 엔진에서 규칙이 꺼져 있으면(응답의 `isActive: false` — 7단계) 머리 아래 노란 줄(`MailFilterNotice` 를 단추 없이 — `actionLabel` 을 주지 않는다). 칸을 고친 값이 아니라 저장한 값으로 보고, `isActive` 는 PUT 본문에 넣지 않는다(`useMail().saveVacation` 이 일곱 칸만 — 16단계의 `htmlBody` · `external` 까지) — 계약 1절 · 2절 "부재중 자동 응답" |
| 여러 통 처리 · 삭제 | `src/components/mail/MailCheckbox.vue` · `MailSelectionToolbar.vue`(+ `MailBulkButton` · `MailBulkMoveButton`) · `MailPurgeDialog.vue` · `MessageDeleteButton.vue` · `composables/useBulkMailActions.ts` · `useMessageRemoval.ts` · `useMailMoveFeedback.ts` · `shared/utils/mail-selection.ts` · `mail-bulk.ts` | 100통씩 차례로. 읽음 · 중요는 먼저 바꾸고 `failedIds` 만 되돌리고, 이동 · 삭제는 응답의 id 만 뺀다. **웹은 `DELETE /api/messages/{id}` 를 쓰지 않는다** — 휴지통 밖 삭제는 `PATCH` 이동(+ 되돌리기), 휴지통에서는 확인 대화상자(처음 초점 「취소」) 뒤 `POST /message-deletions`. 읽기 화면도 그 메일의 `mailboxIds` 로 같은 규칙을 탄다. 도구 줄은 role="toolbar" 로빙 tabindex(바쁜 · 꺼진 단추는 건너뛴다). [되돌리기] 뒤 알림은 한 메일함이면 그 이름(`restoredMessageText(…, mailboxName)` — "메일 N통을 받은편지함으로 되돌렸습니다.") — 계약 2절 "여러 통 한꺼번에". [이동 ▾]의 옮길 곳 · 스팸 표시 · 끌어다 놓기는 아래 「메일 옮기기 · 끌어다 놓기 · 좁은 읽기 칸 (15단계)」 행 목록 보기의 줄(`MessageTableRow` · `ConversationTableRow`)은 줄 전체에 click 을 걸고 단추 · 링크 · 고르기 칸 · 별 칸이면 열지 않는다 — **무엇을 눌렀는지는 `shared/utils/event-path.ts` 의 `eventPathHas`(사건이 지나간 길 — dispatch 때 정해진다)로 가르고 `event.target.closest` 로 가르지 않는다**: 고르기 상자 가운데를 누르면 target 은 빈 상자 그림(`<rect>`)인데, 단추의 처리기가 고르기를 바꾸면 브라우저가 처리기 사이에 microtask 를 돌려 Vue 가 다시 그리고 그 그림이 문서에서 떨어져 줄의 처리기에서 closest('button') 이 비었다 — 고르면서 메일까지 열렸다(2026-11-10 사용자 보고). 상자 그림(`MailCheckbox` 의 svg) · 표의 별 그림은 `pointer-events: none` — 누른 것이 늘 단추다. 고르기 칸의 빈자리(단추 밖 · td 안)는 고르기 상자를 누른 것과 같다(Shift 는 범위 · 고를 수 없는 줄이면 아무 일도 없다), 별 칸의 빈자리는 아무 일도 없다. Ctrl(⌘) · Shift + 클릭은 줄의 다른 곳에서 지금처럼 고른다. 분할 보기는 고르기 칸(36)이 링크 밖이라 탈이 없다. 시험 `components/mail/MailListRows.check.spec.ts` · `shared/utils/event-path.spec.ts` — 스크립트의 dispatchEvent 는 처리기 사이에 microtask 를 돌리지 않아 칸의 처리기에서 그림을 떼어 내 흉내 낸다(실제 다시 그리기는 브라우저로 확인) |
| 설정 › 비밀번호 · 부재중 (4단계) | `src/pages/settings/AccountPasswordPage.vue` · `MailVacationPage.vue` · `components/settings/`(`SettingsPageFrame` 공용 틀 — 머리 오른쪽 단추는 `actions` 슬롯 · `PasswordChangeForm` · `PasswordField` · `Vacation*`) · `stores/mail-vacation.store.ts` · `shared/utils/password-change.ts` · `mail-vacation.ts` · 메뉴 `shared/constants/settings-menu.ts` | 메뉴는 묶음(계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 · 필터(6단계) · 알림(5단계) / 정보 › 업데이트 내역), 라우트 이름은 `SETTINGS_ROUTES` 하나. 비밀번호는 서버와 같은 규칙을 먼저 보고, 서버 거절(`AUTH_005` · `VAL_001`)은 칸 아래에만(알림 없음) — 성공하면 세 칸을 비운다. 부재중은 날짜만 고르고 브라우저 시간대의 시작일 0시 · 끝나는 날 다음 날 0시를 UTC 로 보낸다(변환은 `mail-vacation.ts` 한 곳 — 되읽을 때는 1ms 앞 날짜). 켤 때만 검사하고 꺼 둔 채로는 빈 값도 저장한다(길이 150 · 600 도 켤 때만 — 꺼 둔 채로는 4단계 한도까지, 끄기는 늘 저장되게 — 16단계부터 내용은 편집기, 아래 「부재중 서식 본문 · 회사 밖 응답 (16단계)」 행). 부재중 상태는 메일 화면에 들어올 때 받고, 패널 안내 `components/mail/MailVacationNotice.vue` 가 1분마다 다시 계산한다(`useClock` · `VACATION_NOTICE_REFRESH_MS`). 저장 응답은 세션 세대(`authStore.generation`)가 바뀌었으면 쓰지 않는다. 저장한 값이 켜짐인데 엔진에서 규칙이 꺼져 있으면(응답의 `isActive: false` — 7단계) 머리 아래 노란 줄(`MailFilterNotice` 를 단추 없이 — `actionLabel` 을 주지 않는다). 칸을 고친 값이 아니라 저장한 값으로 보고, `isActive` 는 PUT 본문에 넣지 않는다(`useMail().saveVacation` 이 일곱 칸만 — 16단계의 `htmlBody` · `external` 까지) — 계약 1절 · 2절 "부재중 자동 응답" |
| 메일함 관리 · 오래 걸리는 처리 (4단계) | `src/components/mail/MailboxTree.vue` · `MailboxPanelRow.vue` · `MailboxMoreButton.vue`(⋯ 메뉴) · `MailboxDialogs.vue`(+ `Mailbox*Dialog.vue`) · `MailboxEmptyButton.vue`(목록 머리 「비우기」) · `composables/useMailboxActions.ts` · `stores/mail/mailbox-admin.ts` · `shared/utils/mailbox-operation.ts` · 상수 `shared/constants/mailbox-manage.ts` | 만들기 · 이름 바꾸기는 칸 안 실패(`MAIL_009` · `VAL_001`)를 알림 없이 칸 아래에. 모두 읽음 · 비우기 · 지우기는 남은 수가 0(지우기는 `deleted: true`)이 될 때까지 다시 보내며 진행 알림을 고친다 — 진행은 응답의 `processed`, 세션이 바뀌면 멈춘다(`epoch` — 늦게 온 만들기 · 이름 바꾸기 응답도 버린다), 같은 메일함의 같은 처리는 하나, **휴지통 비우기와 메일함 지우기는 함께 돌리지 않는다**(비우기는 그사이 휴지통에 들어온 메일도 지운다 — 판정은 스토어의 `isMailboxOperationBlocked` 하나, 메뉴 · 단추 · 대화상자 확인이 모두 이것을 본다). 끝난 뒤 누른 단추가 사라져 초점을 잃었으면 패널의 지금 메일함 항목으로 옮긴다. ⋯ 메뉴는 보이는 영역 안에 다 들게 연다(`shared/utils/popup-placement.ts` — 아래가 모자라면 위로 · 좌우가 넘치면 반대 끝으로(15단계 — EditorPopup)). 되돌릴 수 없는 일은 확인 대화상자(처음 초점 「취소」) 뒤에, 확인한 그 메일함만 — 계약 2절 "메일함 관리" |
| 스팸 · 원문 (4단계) | `src/components/mail/MessageSpamButton.vue`(읽기 화면) · 도구 줄의 스팸 단추(`MailSelectionToolbar.vue`) · `MessageMoreMenu.vue`(⋯ › 원문 내려받기) | 스팸 신고 · 스팸 아님은 이동과 `isJunk` 를 한 요청에 보내고(`mailStore.moveMessages(…, { isJunk })`), [되돌리기]는 원래 메일함으로 반대 값을 보낸다. 원문(.eml)은 `useMail().downloadMessageSource()` — 첨부처럼 `confirmSession()` 을 먼저 거친다 — 계약 2절 "스팸 · 원문" |
| 메일 필터 (6단계) | `src/pages/settings/MailFilterPage.vue` · `components/settings/MailFilter*.vue`(목록 · 행 · 목록 위 줄 `MailFilterNotice` · 빈 상태 · 안내 상자 `MailFilterNotes` · 고치기 창 `MailFilterDialog` + 조건 `MailFilterConditionFields` · `MailFilterConditionRow` · 할 일 `MailFilterActionFields`) · `mail-filter-ui.ts` · `stores/mail-filter.store.ts` · `composables/useMailFilterActions.ts`(켜기 · 순서 · 지우기 · 되돌리기 알림) · `useMailFilterDialog.ts`(창 상태) · `shared/utils/mail-filter.ts`(검사 · 요약 · 옮길 곳 · 되돌릴 자리) · `shared/constants/mail-filter.ts` · 읽기 화면 ⋯ 「이런 메일로 필터 만들기」(`MessageMoreMenu.vue` → `/settings/mail/filters?from=`) | 바꾸는 일은 모두 `PUT /api/mail-filters` 전체 교체(받은 `version`)이고 **저장은 한 번에 하나**(`saving`). 켜기 · 순서는 화면을 먼저 바꾸고 실패하면(그사이 목록이 안 바뀌었으면) 되돌린다. 창의 저장은 그 순간 목록의 켜짐 값을 쓴다. [되돌리기]는 지운 필터만 앞 · 뒤 필터 기준 자리에 다시 넣고, 누른 차례대로 앞 저장이 끝난 뒤 보낸다(`restoreQueue` — 기다림과 `send()` 사이에 await 를 두지 않는다 — 2026-10-28 리뷰의 경합). `MAIL_012` 는 목록 위 줄 + 창 안 안내와 [다시 불러오기](쓴 내용을 지키고 다시 받은 목록 위에 저장), 늦게 온 409 는 보낼 때의 순번(seq)이 그대로일 때만 줄을 켠다. 검사는 서버와 같은 규칙(전달 주소의 @ 앞은 dot-atom · 64자로 서버보다 좁게)이고 거절은 칸 아래(`VAL_001` · `MAIL_013` 은 알림을 끈다). 필터 화면이 떠 있지 않을 때 실패한 되돌리기는 알림으로(`attachPage` · `pageVisible`). 세션이 바뀌면 늦은 응답을 버린다(`authStore.generation`) — 계약 2절 "메일 필터", 디자인 README "6단계" |
| 이미 받은 메일에 필터 적용 (7단계) | `src/components/settings/MailFilterApplyField.vue`(고치기 창의 「받은편지함에 이미 있는 메일도 정리하기」 — 창을 열 때마다 꺼짐) · `composables/useMailFilterRun.ts`(진행 · 결과 알림) · `stores/mail-filter-run.store.ts`(cursor 를 이어 보내기 — 탭마다 하나 `running`) · `shared/utils/mail-filter-run.ts`(할 일 판정 · 보낼 규칙 · 끝 알림의 메일함 · 문구) | 저장이 성공한 뒤에만 `POST /api/mail-filter-runs` 를 `cursor` 가 null 이 될 때까지 다시 보내며 진행 알림을 고친다(4단계 오래 걸리는 처리와 같은 결 — 전체는 확인 + `remaining`, 첫 응답 전에는 받은편지함의 메일 수). 전달은 보내지 않는다. 이미 받은 메일에 할 일이 없는 필터(옮길 메일함이 지워짐 · 받은편지함으로 옮기기만 · 전달만)는 칸을 끄고 시작하지도 않는다 — 판정은 `filterRunIdleReason` 하나(칸 `filterApplyBlock` · 시작 `filterRunRuleOf` 가 함께 쓴다), 이 탭에서 적용이 도는 중에도 칸을 끈다. 요청은 `silent` 로 보내고 실패는 응답이 온 때 세션이 그대로일 때만 스토어가 알린다(`REQ_001` — 다른 창 · PC 에서 적용 중 — 은 화면이 "다른 창에서 …"로). 멈추면 [다시 시도](받은 `cursor` 부터 — 수도 이어 센다), 나아가지 못한 응답이 거듭되면(`MAIL_FILTER_RUN_MAX_STALLS`) 멈춘다. 다른 화면으로 가도 이어 하고, 세션이 바뀌면 다음 요청을 보내지 않고 알림을 닫는다. 끝났거나 나아간 뒤 멈췄으면 개수 · 목록을 맞춘다 — 계약 2절 "이미 받은 메일에 적용", 디자인 README "7단계" |
| 주소록 (8단계) | `src/pages/contacts/`(`ContactsLayout.vue` — 패널 · 창 묶음 `ContactsDialogs` 의 자리 · `ContactsPage.vue`) · `components/contacts/`(패널 `ContactsPanel` · 그룹 줄 `ContactsGroupRow` · 목록 `ContactsList` · `ContactsListItem` · 상세 `ContactDetail` · 찾기 `ContactsSearchBox` · 연락처 창 `ContactDialog` + 줄 `ContactEntryRows` · 그룹 이름 창 · 구성원 넣기 창 `ContactGroupMembersDialog` · ⋯ `ContactMoreMenu` · `ContactAvatar` · `contacts-ui.ts`) · `stores/contacts.store.ts` · `composables/useContacts.ts`(API) · `useContactActions.ts`(지우기 · [되돌리기] · 그룹에 메일 쓰기 · 고치기 열기) · `useContactsDialogs.ts`(창 상태) · `useContactDuplicateCheck.ts`(같은 주소 경고) · `shared/utils/contacts.ts`(차례 `compareContacts` — **backend `contact-search.ts` 와 짝** · 칸 정리 · 바꾼 칸 `contactPatchOf` · 검사) · `contacts-route.ts` · `shared/constants/contacts.ts`(라우트 `CONTACTS_ROUTES` · 한도) · `shared/utils/clipboard.ts` | 레일의 주소록 모듈(`app-modules.ts`). 고치기는 창을 열 때 그 연락처를 새로 받고 **바꾼 칸만 `PATCH`** 한다(검사도 바꾼 칸만 — 손대지 않은 남의 값은 그대로, 바꾼 것이 없으면 보내지 않는다). 지우기는 확인 없이 하고 [되돌리기]는 지우기 응답의 `restoreToken` 으로 `POST /contact-restorations`(표가 없으면 단추 없이 알린다). 바꾼 연락처는 응답을 서버의 차례로 받은 쪽 안의 자리에 넣는다(스토어의 `placeContact` — 자리를 모를 때만 첫 쪽을 한 번 다시 받는다, 바꿀 때마다 불러온 쪽을 모두 다시 받지 않는다). 구성원 넣기 창은 `excludeGroupId` 로 받고, 주소로 연 사내 항목은 `GET /directory-entries/{id}` 로. 창은 주소록 틀(`ContactsLayout`)에 둔다 — 주소록 안에서 옮겨 다녀도 남고, 떠날 때는 나가기 확인(`useDialogLeaveConfirm`), 세션이 바뀌면 닫는다. 저장 중에 창을 닫았으면 창 안에 보일 거절도 `notifyApiError` 로. 이메일은 서버보다 좁게 본다(`mail-address.ts` 의 `isStrictEmail` — 필터의 전달 주소와 공용), 이메일 칸은 `type="text"` + `inputmode="email"`(브라우저가 한글 도메인을 punycode 로 바꾸지 않게). 전환은 `.segmented--outline`(base.css), 도구 단추 `components/mail/MailToolButton.vue` 의 `unavailable`(aria-disabled — 클릭 무시) · `busy`(aria-busy) · `describedby` — 계약 3절, 디자인 README "8단계" |
| 메일과 함께 (8단계) | `shared/utils/compose-prefill.ts` · `components/mail/MailContactDialogHost.vue` + `composables/useMailContactDialog.ts` · `MessageMoreMenu.vue`(⋯ 「보낸 사람을 주소록에 추가」 · 「주소록에서 보기」) · 받는 사람 칸의 그룹(`useRecipientSuggestions` 의 `optionsFor` · `expandGroup`, `recipient-match.ts` 의 `matchContactGroups` · `arrangeRecipientOptions`) | 쓰기 화면을 받는 사람을 채워 열 때는 라우터 상태(`composeTo` — 주소는 URL 에 싣지 않는다)에 연 직원의 주소를 함께 싣고, 지금 직원과 다르면 쓰지 않는다(같은 탭에서 다른 직원이 로그인한 뒤 뒤로 가기). 읽기 화면의 연락처 추가 창은 메일 레이아웃(`MailLayout` 이 provide)에 둔다 — 메일을 바꾸거나 목록으로 돌아가도 남는다(읽기 화면 안 `v-if="message"` 에 두면 사라진다). 받는 사람 추천은 주소록 → 그룹 → 사내 → 최근. 그룹은 쓰기 화면을 열 때 받아 둔 목록에서 이름으로 찾고, 고르면 구성원을 받아 대표 주소를 넣는다 — 그룹마다 따로 기다려 이어 골라도 앞 그룹이 빠지지 않는다 — 계약 3절 "메일과 함께" |
| 보내기 취소 · 예약 발송 (9단계) | `src/components/mail/ComposeFooter.vue`([보내기 ▾] — 메뉴 자리는 `composables/usePopupPlacement.ts`, 메일함 ⋯ 과 공용) · `ComposeScheduleDialog.vue` · `shared/utils/schedule-send.ts`(고르기 · 한도 · `rejectedScheduleProblem`) · `shared/utils/pending-send.ts`(`isHeldMessage` · `isScheduledMessage` — 응답의 `pendingSend` 는 이것으로만 읽는다) · `stores/mail/scheduled.ts` · `composables/useScheduledMessageActions.ts` · `usePendingSendRefresh.ts` · `components/mail/MailboxScheduledRow.vue` · `ScheduledMessageList.vue` · `ScheduledMessageItem.vue` · `MessageReaderNotices.vue` · 마감 알림 `toast.store` 의 `expiresAt` + `components/feedback/ToastCountdown.vue` | 기다리는 곳은 엔진이다(계약 2절 "보내기 취소 · 예약 발송"). 보내기 취소 알림의 마감은 설정한 초를 알면 "지금 + 초 − 1초"(설정 스토어의 `knownUndoSendSeconds` — 메일 화면에 들어올 때 `ensureLoaded`), 모르면 min(sendAt, 지금 + 30초). 마감 알림은 멈추지 않고 넘쳐도 먼저 닫히지 않는다. 알림 단추는 `toastStore.takeAction(id)` 로 한 번만, 같은 메일의 취소는 스토어가 요청 하나로 묶는다. 초점이 든 알림이 닫히면 들어오기 전 요소로(없으면 `<main>` — `data-focus-fallback`). **붙잡힌 메일은 취소로만 보낸편지함을 떠난다** — 고르기 칸 `MailCheckbox` 의 `unavailable` · `note`(aria-disabled + 까닭), 고르기 · 범위 · 모두 고르기 · `checkedMessages` 가 건너뛰고, 읽기 화면 [삭제]는 확인(처음 초점 「취소」) → 취소 → 휴지통(서버도 `MAIL_015` 로 막는다). 엔진이 풀어도 상태가 바뀌지 않아 `usePendingSendRefresh` 가 가장 이른 시각 + 2초(남으면 5초 × 3)에 목록 · 열린 메일 · 「예약됨」 수를 다시 받는다. 응답을 기다린 뒤 다음 메일로 옮기는 일(`useMessageRemoval` 의 `leaveRemoved(…, messageId)`)은 같은 목록 · 같은 메일을 보고 있을 때만 — 디자인 README "9단계" |
| 보내기 취소 · 예약 발송 (9단계) | `src/components/mail/ComposeFooter.vue`([보내기 ▾] — 메뉴 자리는 `composables/usePopupPlacement.ts`, 메일함 ⋯ 과 공용) · `ComposeScheduleDialog.vue` · `shared/utils/schedule-send.ts`(고르기 · 한도 · `rejectedScheduleProblem`) · `shared/utils/pending-send.ts`(`isHeldMessage` · `isScheduledMessage` — 응답의 `pendingSend` 는 이것으로만 읽는다) · `stores/mail/scheduled.ts` · `composables/useScheduledMessageActions.ts` · `usePendingSendRefresh.ts` · `components/mail/MailboxScheduledRow.vue` · `ScheduledMessageList.vue` · `ScheduledMessageItem.vue` · `MessageReaderNotices.vue` · 마감 알림 `toast.store` 의 `expiresAt` + `components/feedback/ToastCountdown.vue` | 기다리는 곳은 엔진이다(계약 2절 "보내기 취소 · 예약 발송"). 보내기 취소 알림의 마감은 설정한 초를 알면 "지금 + 초 − 1초"(설정 스토어의 `knownUndoSendSeconds` — 메일 화면에 들어올 때 `ensureLoaded`), 모르면 min(sendAt, 지금 + 30초). 마감 알림은 멈추지 않고 넘쳐도 먼저 닫히지 않는다. 알림 단추는 `toastStore.takeAction(id)` 로 한 번만, 같은 메일의 취소는 스토어가 요청 하나로 묶는다. 초점이 든 알림이 닫히면 들어오기 전 요소로(없으면 `<main>` — `data-focus-fallback`). **붙잡힌 메일은 취소로만 보낸편지함을 떠난다** — 고르기 칸 `MailCheckbox` 의 `unavailable` · `note`(aria-disabled + 까닭), 고르기 · 범위 · 모두 고르기 · `checkedMessages` 가 건너뛰고, 읽기 화면 [삭제]는 확인(처음 초점 「취소」) → 취소 → 휴지통(서버도 `MAIL_015` 로 막는다). 엔진이 풀어도 상태가 바뀌지 않아 `usePendingSendRefresh` 가 가장 이른 시각 + 2초(남으면 5초 × 3)에 목록 · 열린 메일 · 「예약됨」 수를 다시 받는다. 응답을 기다린 뒤 다음 메일로 옮기는 일(`useMessageRemoval` 의 `leaveRemoved(…, messageId)`)은 같은 목록 · 같은 메일을 보고 있을 때만 — 디자인 README "9단계" 줄은 스크롤하는 칸의 아래에 붙는다(sticky · 2026-11-10) |
| 외부 이미지 · 인쇄 (9단계) | `src/components/mail/MailBodyFrame.vue`(`imagesShown`) · `shared/utils/mail-html.ts`(`mailFrameCspImagesShown` · `PROXIED_IMAGE_PATTERN`) · `mailStore.showImages()` · 인쇄 `pages/mail/MailPrintPage.vue` + `composables/useMailPrint.ts` · `shared/constants/mail-print.ts` | [이미지 보기]는 `GET /messages/{id}?images=show` 로 그 메일의 본문 칸만 다시 받는다. 본문 틀의 CSP 는 보일 때만 그림에 **우리 출처의 중계 경로**(`<origin>/api/mail-images/`)를 연다 — `'self'` 로 열면 정화를 빠져나간 CSS `url()` 이 다른 API 를 부를 수 있다(2026-10-29 리뷰). 중계 주소 모양은 짝: backend `images/mail-image-token.ts` 의 `TOKEN_PATTERN`. 인쇄는 새 탭(`window.open` 은 누름 처리 안에서 바로), A4 폭(210mm — @page 와 같은 값, 함께 고친다)에서 본문 높이를 다시 잰 뒤 `window.print()` 한 번. Ctrl+P 판정은 `keyboard-shortcut.ts`(조합 중 · 다른 수식키는 브라우저 인쇄, `code === 'KeyP'` 도) |
| 보낸 사람 차단 · 보내기 · 읽기 설정 (9단계) | `src/composables/useSenderBlock.ts` · `components/mail/MessageBlockDialog.vue` · `stores/mail-block.store.ts` · `shared/utils/mail-block.ts` · `shared/constants/mail-block.ts`(`PUBLIC_MAIL_DOMAINS`) · `pages/settings/MailBlocksPage.vue` · `MailSendReadPage.vue` · `stores/mail-settings.store.ts` · `composables/useMailSettings.ts` · 설정 틀의 `title-aside` 슬롯(`SettingsPageFrame`) | 차단은 엔진의 Sieve(필터 · 부재중과 같은 스크립트 — 계약 2절 "보낸 사람 차단"). 여러 사람이 쓰는 메일 서비스 목록은 **짝: backend `mail.constants.ts`**(30개 · 같은 차례 — 한쪽을 고치면 둘 다). 차단 · 풀기가 성공하면 엔진 규칙이 켜진 것으로 본다(`isActive`). [되돌리기]는 원래 메일함으로. 설정은 고르는 대로 `PATCH` — **칸마다 저장은 한 번에 하나**(보내는 동안 고른 값은 끝난 뒤 다르면 보낸다, 끝내 실패하면 서버가 확인한 값으로 — 6단계 필터의 `saving` 과 같은 결) |
| 대용량 첨부 (11단계) | 올리기 엔진 `src/composables/useLargeFileUploads.ts` · 쓰기 화면 잇기 `useComposeLargeFiles.ts` · 첨부 칸 `components/mail/ComposeAttachmentGroups.vue` · `ComposeLargeFileItem.vue` · 읽기 `MessageAttachments.vue`(+ `MessageLargeFileItem` · `MessageLargeFileLinkBar` · `LargeFileRevokeDialog`) · 검사 다시 묻기 `useLargeFileScanRefresh.ts` · 위험 알림 `useLargeFileAlerts.ts`(실시간 `large-file`) · 설정 `pages/settings/MailLargeFilesPage.vue` + `components/settings/LargeFile*.vue` · `stores/large-files.store.ts` · API `useLargeFiles.ts` · 공개 화면 `pages/share/SharePage.vue` + `usePublicShare.ts` · `shared/utils/large-file.ts` · `large-file-status.ts` · `kst-date.ts` · `share-links.ts` · `shared/constants/large-file.ts` | 20MB 넘는 파일 · 합계 25MB 를 넘기는 파일 · 메일에 못 담는 형식은 저절로 링크로(묻는 창 없음). **올리기**: 고르자마자 서버에 모두 만들고(임시 저장본에 바로 실림) 조각(16MiB · `Content-Digest`)은 파일 3개까지만 함께, 끊기면 2 · 5 · 15 · 30초로 다시, 1분 넘으면 멈춤 + [다시 시도], 404 는 저절로 다시 올리지 않는다, 새로고침 뒤 같은 파일(이름 · 크기 · 고친 시각 + 맨 앞 조각 SHA-256)을 다시 고르면 잇는다. 다 올릴 때까지 [보내기] · ▾ 는 `aria-disabled` + 까닭(누르면 role=status). **본문**: 대용량 파일이 있을 때만 첫 원문 인용 앞에 `<div data-ara-quote-start="">` 와 `bodyQuoteStart`(텍스트 인용 줄의 처음)를 보낸다 — 서버가 그 자리에 덩이를 넣는다. 다시 연 저장본의 원문 인용은 `mail-extensions.ts` 의 `isMailQuoteBlock`("원본 메일" 머리 문단 + 머리 표)으로 알아본다. **읽기**: 첨부 칸의 대용량 항목은 서버의 `largeFiles` 에서만 오고, 본문 덩이는 서버만 붙일 수 있는 `data-ara-large-files` 요소만 `<template>` 안에서 접는다(`mail-html.ts`). **공개 화면** `/s/:token` 은 `meta.public`(세션 복원을 건너뛴다 — 이 라우트 하나뿐), 요청은 `credentials: 'omit'` · `no-store` · `no-referrer`, v-html 없음. 서버 거절은 사전 문구(`getErrorMessage`), 화면 상수 글은 서버에 묻기 전 검사뿐 — 계약 4절, 디자인 README "11단계" |
@@ -76,6 +77,7 @@ description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API
| 공용 편집기 (16단계) | `components/mail/editor/signature/SignatureEditor.vue` · `SignatureToolbar.vue` · `signature-extensions.ts` | 서명 칸 · 부재중 자동 응답 내용 칸이 함께 쓴다(TipTap 청크 — 쓰는 화면이 지연 로딩). prop `bodyId` · `toolbarLabel` · `allowImages`(false 면 그림 단추 · 그림 노드 없음 · 파일 무시) · `invalid` · `disabled`, 최소 높이 CSS 변수 `--signature-editor-min-height`, 보이는 글 `getText()` · 보낼 모양 `getContent()`(쓰기 본문과 같은 `MailEditorContent`). 기본값은 서명 칸 그대로 |
| 부재중 서식 본문 · 회사 밖 응답 (16단계) | `src/components/settings/VacationForm.vue`(설정 화면 `MailVacationPage` 이 `defineAsyncComponent` 로 양식째 지연 로딩 — `ComposeEditorLoadError` · `MAIL_EDITOR_RETRY`) · `VacationMessageFields.vue`(제목 + 내용 편집기 — `idPrefix` 로 사내 `vacation` · 회사 밖 `vacation-external`) · `VacationExternalFields.vue`(회사 밖에서 온 메일 — 고르기 셋 + 따로 쓰기 칸 슬롯) · `VacationPreview.vue`([사내 \| 회사 밖]) · `shared/utils/mail-vacation.ts`(`vacationExternalOf` · `vacationProblems` · `firstVacationProblem` · `vacationPayloadOf` · `vacationPreviewNote` · `vacationPreviewSendsNothing` · `vacationPreviewUsesExternal` · `emailDomainOf`) · `shared/constants/mail-vacation.ts`(`VACATION_EXTERNAL_MODES` · `VACATION_HTML_MAX_LENGTH`) · 타입 `shared/types/mail.ts` 의 `MailVacationExternal` | 계약 2절 "부재중 자동 응답"(16단계). **내용 칸은 서명 칸과 같은 편집기**(`SignatureEditor` — `allowImages: false` · `bodyId` · `toolbarLabel` · `invalid` · `disabled`, 높이 150 은 `--signature-editor-min-height`) — TipTap 청크라 양식째 지연 로딩한다(양식의 부품은 화면에서 바로 import 하지 않는다). 글자 수(600)는 편집기의 `getText().length`(서버가 받는 `textBody` 와 같다 — 링크는 "글 (주소)"), 켜 둔 동안 넘으면 빨갛게. **서식 본문은 서식이 하나라도 있을 때만**(`VacationMessageFields.getContent` — 편집 중인 문서에 표시(marks: 굵게 · 기울임 · 밑줄 · 글자 색 · 링크)가 있는지 `hasVacationFormatting`, 빈 글자 스타일은 빼고) `getContent().html`, 글만이면 null — 사내 · 회사 밖 따로, 끄면서 하는 저장도(서버가 새 칸을 쓴 직원만 원본 판 3 으로 저장한다 — 되돌리기 계획의 전제, 16단계 리뷰). 서식 본문 10,000자 넘으면 `VAL_001`. 저장 거절 `MAIL_013` 은 알림을 끄고 [저장] 옆에 `VACATION_TEXT.saveTooLong`(서식이 두 번 들어가 필터가 없어도 넘을 수 있다 — 화면을 떠났으면 그 글로 알림). 16단계 전에 저장한 글만 있는 응답은 `textToParagraphHtml` 로 문단을 만들어 연다. **회사 밖 응답**: 고르기 차례 사내와 같은 응답(처음 값) · 보내지 않기 · 따로 쓰기(열리는 칸이 맨 끝) — 따로 쓰기의 두 칸은 `v-show` 로 닫아 다른 고르기로 바꿔도 편집기째 남기고, 저장은 고르기와 상관없이 따로 쓴 제목 · 내용을 함께 보낸다(쓴 것이 없으면 null). 검사는 켤 때만 · 회사 밖 두 칸은 따로 쓰기일 때만, 보는 차례 `VACATION_PROBLEM_ORDER`(기간 · 제목 · 내용 · 회사 밖 제목 · 회사 밖 내용), 600자 넘음은 전용 글(`bodyTooLong`)이고 그 밖은 사전 문구. 바꿈은 칸(`sameVacationForm` — 고르기 · 회사 밖 제목 포함) + 두 편집기의 `isChanged()`(저장하면 `captureSavePoint` → `markSaved`). 미리 보기는 편집 중인 문서를 편집기 스키마로 그린다(`DOMSerializer` — 서명 미리 보기와 같다, v-html 없음). **`useMail().saveVacation` 은 일곱 칸을 늘 보낸다**(`htmlBody` · `external` — 주지 않으면 글만 · 사내와 같은 응답), 응답에 새 칸이 없거나 모양이 틀리면(16단계 전 서버) `vacationStateOf` 가 글만 · 사내와 같은 응답으로 맞춘다 — 타입의 두 칸이 optional 인 까닭(메일함 패널 안내는 4단계 칸만 본다) — 디자인 README "16단계 › 부재중" |
| 2단계 인증 휴대폰 바꾸기 (16단계) | `src/components/settings/TwoFactorReplaceDialog.vue`(창 — 두 걸음) · `TwoFactorReplaceSteps.vue`(차례 "새 휴대폰 등록 › 지금 휴대폰으로 확인") · QR · 키는 `TwoFactorEnrollmentScan`(켜기 창과 공용 — `title` · `discardNote` · `keyLabelId` 를 받는다) · 문의 줄 `LoginSupportLine` · API `composables/useAccountSecurity.ts`(`verifyTwoFactorEnrollment` · `replaceTwoFactor`) · 타입 `shared/types/account-security.ts`(`TwoFactorVerificationPayload` · `TwoFactorReplacementPayload`) · 글 `shared/constants/account-security.ts` 의 `TWO_FACTOR_REPLACE_TEXT` · 자리 `pages/settings/AccountTwoFactorPage.vue`([2단계 인증 끄기] · 끌 수 없다는 글 앞) | 계약 5절 "2단계 인증 — 휴대폰 바꾸기". ① `POST /two-factor/enrollments`(켜져 있어도 — 열 때마다 새 키) → `…/enrollments/{id}/verification`(새 휴대폰의 코드) ② `POST /two-factor/replacements`(지금 코드 · 지금 비밀번호 — 한도 30초 `TWO_FACTOR_CHANGE_TIMEOUT_MS`). **요청은 창이 보낸다**(Pinia 액션 금지 — 12단계 규칙), 키 · 코드 · 비밀번호는 창 메모리에만 · 닫을 때와 세션 세대(`authStore.generation`)가 바뀔 때 지운다, `openSeq` 로 닫았다 다시 연 창에 앞 요청의 결과를 넣지 않는다. 칸 아래 거절: ① `AUTH_006` / ② `AUTH_006` · `AUTH_005`(silent). `AUTH_008` 은 상태를 다시 받아(`twoFactorStore.refresh`) 꺼졌으면 창을 닫고 사전 문구 알림, 아니면 ①로 돌아와 창 위 상자 + 새 키. **결과를 모르면**(`MAIL_001` · `SYS_001` — silent) 단정하지 않는 글(`unconfirmed` — 창 위 상자, ② 그대로 · 닫힌 뒤면 `unconfirmedClosed` 알림). 바꾸면 스토어에 `apply` + 알림 `done`(창이 띄운다 — 닫았거나 화면을 떠난 뒤 왔어도), 화면은 초점을 [휴대폰 바꾸기]로. [이전]은 같은 키로 ①(새 코드 칸을 비우고 다시 확인 — 지금 비밀번호는 창을 닫을 때까지 남긴다). 끌 수 없는 직원에게도 같은 단추 — 디자인 README "16단계 › 휴대폰 바꾸기" |
| 업데이트 내역 (2026-11-10) | 목록 `src/pages/settings/InfoReleaseNotesPage.vue`(+ 칸 `components/settings/ReleaseNoteList.vue`) · 글 `pages/settings/InfoReleaseNotePage.vue`(+ `components/settings/ReleaseNoteArticle.vue` · 다른 글 줄 `ReleaseNoteNeighbors.vue`) · `stores/release-notes.store.ts` · API `composables/useReleaseNotes.ts` · 응답 읽기 · 글 id 꼴 · 다른 글 차례 `shared/utils/release-notes.ts` · 타입 `shared/types/release-notes.ts` · 글 `shared/constants/release-notes.ts`(글의 원본 — 보드 ReleaseNotes 그대로) · 달력 날짜 `shared/utils/format.ts` 의 `formatCalendarDate` · 스크롤 `composables/useSettingsPageScroll.ts`(`revealSettingsPageTop`) | 계약 7절 "업데이트 내역" — 글은 운영자가 `backend/release-notes/` 에 파일로 쓰고 웹 · 앱이 같은 글을 읽는다(올리는 화면 · API 없음). 설정 메뉴 셋째 묶음 「정보」 › 「업데이트 내역」(`file-text`), 라우트 `SETTINGS_ROUTES.releaseNotes` · `releaseNote` 를 **화면 없는 상위 라우트로 묶었다**(`info/release-notes` + 하위 `''` · `:id` — 메뉴 링크가 목록 = 빈 하위 경로라 RouterLink 의 `isActive` 가 글에서도 참). 그래서 설정 메뉴(`SettingsLayout`)는 `RouterLink custom` 슬롯으로 `isActive` 면 `is-active`(청록 바탕), `aria-current` 는 `isExactActive` 면 `page` · 아래에 묶은 화면(글)이면 `true`(메일함 패널의 지금 메일함과 같은 결 — 다른 항목은 아래 화면이 없어 그대로). 탭 제목은 상위 meta 하나("업데이트 내역"). `{ path: 'info', redirect }` — `/settings/info` 는 목록으로(주소창의 글 주소 뒤 `..` · `%2E%2E` 를 브라우저가 `/settings/info/` 로 다듬는다), 다른 묶음 주소는 그대로. **날짜는 달력 날짜 글**(`YYYY-MM-DD`)이라 `formatCalendarDate` 로만 쓴다 — `new Date('2026-11-18')` 은 UTC 자정이라 서쪽 시간대에서 하루 앞(시간대를 거치지 않는다 · 없는 날은 ''). 목록 · 글은 들어올 때마다 새로 받고(`silent` — 받지 못함은 `MailLoadError`, 글 없음은 `StateMessage`, 목록의 404 도 빠져나갈 곳이 없어 [다시 시도]), [더 보기]는 20개씩(인터셉터 알림 · 받은 줄 그대로 · 바쁨은 그 단추만), 늦은 응답은 seq + `authStore.generation` 으로 버리고 세대가 바뀌면 스토어를 비운다. 글 화면: [← 목록으로](`MailToolButton` accent · sticky 줄 52) · 다른 글 줄(`RouterLink replace` — 뒤로 가기는 목록) · [목록] — 목록으로 가는 셋은 `useMailNavigation().backOrReplace`(앞 기록이 목록이면 뒤로). 주소의 id 가 글 id 꼴(`isReleaseNoteId` — `2026-11-18(-N)` · 32자)이 아니면 묻지 않고 없는 글("." · ".." 경로 조각이 다른 API 로 가지 않게). 응답은 계약의 모양 그대로일 때만 읽는다(`releaseNotePageOf` · `releaseNoteOf` — 줄 · 묶음 · 다른 글 하나라도 틀리면 `SYS_001`, 앱 `tryParse` 와 같은 규칙). [더 보기]의 모양이 틀린 응답도 `notifyApiError` 로 알리고 받은 줄은 그대로. **스크롤 자리** — 같은 문서 안의 이동이라 앞 화면의 스크롤이 남는다: 글(열기 · 다른 글로)과 목록(받은 뒤 — [더 보기] 뒤는 아님)은 그린 뒤 `revealSettingsPageTop(main, 머리)` — 넓은 화면(70rem 이상)은 글 칸 `scrollTop = 0`, 좁은 화면은 문서를 맨 위로 올려도 머리(h1)가 화면 안이면 0 · 아니면 글 칸 위 끝을 화면 위 끝에, 문서가 맨 위면 그대로(처음 연 화면). 라우터 전역 `scrollBehavior` 는 쓰지 않는다(메일의 뒤로 가기까지 바뀐다). 좁은 폭은 목록 칸 · 다른 글 칸의 컨테이너 쿼리(480 미만 — 머리 줄 감춤 · 날짜를 제목 아래로). 초점: 다른 글로 옮겨 잃은 초점은 새 제목(h1 tabindex -1 · 맞춘 뒤라 `preventScroll`), 위에 붙은 [← 목록으로] 줄 밑에 가리지 않게 글 칸의 제목 · 링크 · 단추는 `scroll-margin-top` 61(줄 52 + 선 1 + 8 — WCAG 2.4.11), 마지막 [더 보기]로 단추가 사라지면 새 첫 줄. 글 내용은 글로만(v-html · 마크다운 해석 없음) — 디자인 README "업데이트 내역" |
| 시계 주입 | `src/composables/useClock.ts` · `src/test-utils/manual-clock.ts` | 타이머를 쓰는 로직(자동 저장 등)은 시계를 주입받는다 — 테스트는 손으로 돌리는 시계로 시간을 넘긴다(고정 대기 없이, [[testing]]) |
| 나가기 전 확인 | `src/composables/useLeaveConfirm.ts` · `src/shared/utils/compose-changes.ts` | 화면을 연 뒤(또는 마지막으로 저장한 뒤) 바꾼 것이 있으면 앱 안 이동(라우트 떠남·바뀜)은 확인 대화상자, 탭 닫기·새로고침은 `beforeunload`. 쓰기 화면은 떠나기 전에 먼저 저장하고 저장하지 못한 것이 있을 때만 묻는다(서명 설정은 저장하지 않은 서명이 있으면 묻는다). 가드가 도는 중은 `guarding`. 바꿈 여부는 연 직후(저장한 뒤에는 마지막으로 저장한 판)의 스냅숏과 비교한다(편집기는 `isChanged()`). 떠나기 전 저장은 5초까지만 기다린다. 보낸 뒤·보내는 중·세션이 끝났을 때는 묻지 않는다 — 보내는 중에 떠나면 결과는 토스트로만 알리고 떠난 양식·새 양식을 건드리지 않는다(양식 세대로 늦게 온 결과를 버린다, 디자인 README "나가기 전 확인"). **고치던 모달 창**(필터 고치기 창 — 7단계)은 `composables/useDialogLeaveConfirm.ts` — 화면의 확인에 "창 닫기" 길을 더한다: [취소] · Esc 는 `asking: 'close'`([계속 고치기]면 창을 그대로 — 브라우저가 닫았으면 `reshow()`), 다른 화면은 `'route'`, 탭 닫기 · 새로고침은 `beforeunload`. 보내는 중 · 세션이 끝났을 때는 묻지 않는다 |
| 읽기 화면 본문 안내 | `src/shared/utils/mail-body-notices.ts` · `shared/constants/mail.ts` 의 `MAIL_BODY_NOTICES` | 서버의 `isHtmlBodyOmitted`·`isBodyTruncated`(계약 2절 "읽기 한도")를 외부 이미지 차단과 같은 안내 줄로 보인다(`.reader__notice[data-notice]`) |
@@ -283,6 +285,8 @@ router.beforeEach((to) => {
편집기로 읽어 들이는 서식은 **서버가 남기는 형식만** 받는다 (`outgoing-html-rules.ts`) — 서버만 지우면 편집기에서 숨은 글(`font-size:0` 등)이
받는 사람에게 보이는 식으로 화면과 보낸 메일이 달라진다 (2026-10-27 리뷰).
이어 쓰는 임시 저장본의 `bodyHtml`·서명 HTML 도 같은 경로로 편집기에 옮긴다 (다른 메일 프로그램이 만든 초안도 있다).
- [ ] **아래에 붙는 줄(sticky)을 새로 만들 때**: 겹침 차례(z-index — 그 줄 위로 뜰 메뉴 · 목록)와 그 줄에 가리는 초점(키보드 · 코드로 옮긴 초점 — 쓰기 화면은 `useFocusAboveFooter`)을 함께 본다
- [ ] **줄 · 칸 전체에 click 을 걸 때**: 안의 단추 · 링크를 가르는 것은 `event.target.closest` 가 아니라 `eventPathHas(event, 선택자)`(`shared/utils/event-path.ts`) — 앞선 처리기가 상태를 바꾸면 처리기 사이에 Vue 가 다시 그려 target(아이콘 그림 등)이 문서에서 떨어진다. 단추 안의 그림은 `pointer-events: none`
- [ ] **lint/test**: 커밋 전 `npm run lint` (`--max-warnings 0`) + `npm run type-check` + `npm run test:unit` 통과. 자동 수정은 `npm run lint:fix`.
테스트 시간 한도는 전역 20초(`vitest.config.ts` — jsdom·편집기 테스트가 부하에서 5~13초)이고, 긴 초안을 맞추는 테스트는 `src/test-utils/timeouts.ts` 의 한도를 쓴다.
"몇 ms 안에 끝난다"를 검사하는 성능 단언은 벽시계 대신 계산량(CPU 시간·비용)으로 잰다 — 벽시계는 다른 작업이 많으면 몇 배로 늘어
+2
View File
@@ -43,6 +43,8 @@ COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=builder /app/dist ./dist
# 업데이트 내역 글(글 파일 하나가 글 하나 — 계약 7절) — 서버가 기동 · 요청 때 /app/release-notes 를 읽기만 한다(modules/release-notes — 쓰지 않는다)
COPY --from=builder /app/release-notes ./release-notes
# 업로드 저장 위치(compose 의 UPLOAD_DIR=/app/uploads)와 대용량 첨부 파일 위치(FILE_STORAGE_DIR=/app/files — 11단계) — node 소유로 만들어 둔다.
# 운영 compose 는 여기에 named volume 을 붙이는데, 새 볼륨은 이 디렉터리의 소유자를 물려받아 node 가 쓸 수 있다.
+51
View File
@@ -0,0 +1,51 @@
# 휴대폰 앱이 나왔고, 메일 검색과 여러 설정이 좋아졌습니다
- 11월 5일 새 웹메일로 옮긴 뒤 새로 생기거나 바뀐 것을 모았습니다. 앞으로 바뀌는 것도 이곳에서 알려 드리겠습니다.
## 휴대폰 앱
- 안드로이드 휴대폰에서 쓰는 ARA SYSTEM 앱이 나왔습니다. 메일을 읽고 쓰고 찾을 수 있고, 답장과 전달, 첨부 파일 보내기도 됩니다.
- 앱 받기: PC 웹메일에서 [설정] → [휴대폰 앱]을 열고 휴대폰 카메라로 QR 코드를 찍어 주세요.
휴대폰 브라우저에서 웹메일에 로그인한 뒤 [앱 내려받기]를 누르고, 설치를 묻는 안내가 나오면 허용해 주세요.
- 앱에는 웹메일과 같은 메일 주소와 비밀번호로 로그인합니다. 2단계 인증을 켜 두셨다면 인증 앱의 코드도 넣어 주세요.
- 새 메일이 오면 휴대폰 알림으로 알려 드립니다. 앱의 [설정] → [새 메일 알림]에서 켜고 끌 수 있고,
알림에는 새 메일이 몇 통인지만 나오며 보낸 사람이나 제목은 나오지 않습니다.
- 아이폰용 앱은 아직 없습니다. 아이폰에서는 브라우저로 웹메일을 쓰거나 기본 메일 앱에 회사 메일을 연결해 주세요.
연결 방법은 웹메일의 [설정] → [메일 프로그램 연결]에서 볼 수 있습니다.
## 웹메일과 휴대폰 앱
- 메일 검색이 빨라졌고, 보낸 사람, 받는 사람, 제목, 본문, 기간, 첨부로 나눠 찾을 수도 있습니다.
웹메일은 검색 칸의 [조건]을, 앱은 검색 화면의 [조건으로 찾기]를 눌러 주세요.
- 찾은 말에는 노란 표시가 붙어 목록과 본문에서 금방 찾을 수 있습니다.
- 메일을 읽다가 [이동]을 누르면 바로 다른 메일함으로 옮길 수 있습니다.
- 업데이트 뒤 하루쯤은 새 검색을 준비하느라 검색 화면에 안내가 나올 수 있습니다. 그동안에도 찾기는 됩니다.
## 웹메일 · 메일 읽기와 정리
- 메일을 열면 그 메일과 주고받은 메일이 아래에 대화로 모여 나옵니다.
[모두 펼치기]로 한꺼번에 읽고, 펼친 메일의 [답장]으로 바로 답할 수 있습니다.
- 목록 위의 [대화로 묶기]를 켜면 메일 목록도 대화별로 묶입니다.
- 목록의 메일을 끌어다 왼쪽 메일함에 놓으면 그 메일함으로 옮겨집니다.
- 메일함 이름 옆 [⋯] → [옮기기]로 메일함을 다른 메일함 아래로 옮길 수 있습니다.
하위 메일함이 있는 메일함도 [⋯] → [지우기]로 한꺼번에 지울 수 있고, 안에 있던 메일은 휴지통으로 옮겨집니다.
- 보낸 사람을 차단할 때 받은편지함에 있는 그 사람의 메일도 스팸메일함으로 함께 옮길 수 있습니다.
메일을 읽다가 [⋯] → [보낸 사람 차단]을 누르고, 창에서 받은편지함의 메일도 옮기는 칸을 켜 주세요.
- 예약한 메일을 기다리지 않고 바로 보낼 수 있습니다. 왼쪽의 [예약됨]에서 메일을 열고 [지금 보내기]를 눌러 주세요.
## 웹메일 · 주소록
- 다른 메일이나 휴대폰에서 내보낸 연락처 파일을 한 번에 가져올 수 있습니다.
[주소록]에서 [가져오기]를 누르고 파일을 고르면, 넣기 전에 몇 명이 들어가는지 먼저 확인할 수 있습니다.
- vCard, CSV, 엑셀 파일을 넣을 수 있고, 네이버, Google, 아이폰, Outlook에서 내보낸 파일도 됩니다.
- 이미 주소록에 있는 연락처는 건너뜁니다. 넣은 뒤 나오는 알림의 [되돌리기]를 누르면 이번에 넣은 연락처를 한꺼번에 지울 수 있습니다.
- 내 주소록을 파일로 내보낼 수도 있습니다. [주소록]에서 [내보내기]를 누르고 vCard나 CSV를 골라 주세요. 그룹 하나만 내보낼 수도 있습니다.
## 웹메일 · 설정
- 서명을 10개까지 만들어 두고 골라 쓸 수 있습니다. [설정] → [서명]에서 [새 서명]으로 만들고,
[메일에 넣을 서명]에서 [새 메일]과 [답장과 전달]에 넣을 서명을 따로 고를 수 있습니다.
- 메일을 쓰다가 아래의 [서명]을 누르면 다른 서명으로 바꿀 수 있습니다.
- 부재중 자동 응답을 굵게, 색깔 같은 글자 모양으로 꾸며 쓸 수 있습니다. [설정] → [부재중 자동 응답]
- 부재중 자동 응답에서 회사 밖에서 온 메일에는 [사내와 같은 응답], [보내지 않기], [따로 쓰기] 가운데 골라 답할 수 있습니다.
- 휴대폰을 바꾸셨다면 2단계 인증을 새 휴대폰으로 직접 옮길 수 있습니다.
[설정] → [2단계 인증] → [휴대폰 바꾸기]를 누르고 안내를 따라 주세요. 지금 휴대폰의 코드와 비밀번호가 필요합니다.
## 그 밖에
- 화면의 안내 글을 더 알기 쉬운 말로 다듬었습니다.
- 앞으로 새로 생기거나 바뀌는 것은 이 업데이트 내역에 올립니다. 웹메일과 앱 모두 [설정] → [업데이트 내역]에서 볼 수 있습니다.
+41
View File
@@ -0,0 +1,41 @@
# 업데이트 내역 글
직원이 웹메일과 휴대폰 앱의 설정에서 읽는 「업데이트 내역」의 글입니다. 글 하나가 파일 하나이고, 웹과 앱이 같은 글을 보여 줍니다. 올리는 화면은 없습니다.
## 글을 더하는 법
1. 이 폴더에 파일을 하나 더합니다. 파일 이름은 글의 날짜입니다(`2026-11-18.md`). 같은 날 두 번째 글은 `2026-11-18-2.md`로 씁니다.
2. 커밋하고 배포합니다(백엔드를 다시 빌드). 웹과 앱에 함께 나옵니다. Gitea 웹 화면에서 파일을 더하거나 고쳐도 됩니다.
고치거나 지울 때도 그 파일만 다룹니다. 목록에는 최근 글이 위에 나옵니다(날짜 차례, 같은 날은 뒤 번호가 위).
## 파일 안
```
# 메일 검색이 빨라지고 휴대폰 앱이 나왔습니다
<!-- 배포 날짜가 정해지면 파일 이름을 그날로 바꿉니다 -->
## 휴대폰 앱
- 안드로이드 휴대폰 앱이 나왔습니다. 웹메일의 [설정] → [휴대폰 앱]에서 받을 수 있습니다.
- 앱에서 메일을 읽고 쓰고 찾을 수 있고, 새 메일이 오면 휴대폰으로 알려 드립니다.
알림에는 새 메일이 몇 통인지만 나오고, 보낸 사람이나 제목은 나오지 않습니다.
```
| 줄 | 뜻 |
| --- | --- |
| `# 제목` | 파일의 첫 줄이고 꼭 있어야 합니다. 목록의 한 줄과 글 화면의 제목이 됩니다(100자까지). |
| `## 머리` | 묶음 머리입니다. 휴대폰 앱, 메일 검색처럼 어디 이야기인지 씁니다(40자까지). |
| `- 한 줄` | 앞에 점이 붙은 한 줄입니다(500자까지). 쓴 글이 그대로 나옵니다. |
| (빈칸 두 개) 글 | 앞 줄에 띄어쓰기 하나로 이어 붙습니다. 긴 줄을 파일에서 나눠 쓸 때 씁니다. |
| `<!-- … -->` | 메모입니다. 화면에 나오지 않습니다. 줄 앞에서 시작합니다(앞에 빈칸은 있어도 되지만, 글 뒤에 붙여 쓰지는 않습니다). 여러 줄로 써도 됩니다. |
| `- `(머리 없이) | 머리 없는 묶음입니다. 고친 것 한두 줄처럼 짧은 글에 씁니다. 제목 바로 아래에 씁니다. |
## 지킬 것
- 날짜는 파일 이름에서 읽으니 파일 안에 다시 쓰지 않습니다. 있는 날짜여야 합니다(`2026-02-30.md`는 안 됩니다).
- 파일 이름은 글 주소가 됩니다. 날짜를 고치면 주소도 바뀌어, 그전 주소로 들어오면 없는 글 화면이 나옵니다.
- 글 하나에 줄(`- `)이 하나는 있어야 하고, 묶음 머리 아래에도 줄이 하나는 있어야 합니다.
- 굵게, 링크, 그림, 표, `###`, 하위 목록은 쓸 수 없습니다. 쓴 글이 그대로 나옵니다. 단추나 메뉴 이름은 `[설정] → [휴대폰 앱]`처럼 씁니다.
- 이 폴더에는 이 안내(`README.md`)와 글 파일만 둡니다. 파일은 UTF-8로 저장합니다.
- 형식이 틀린 글은 시험(CI)이 막습니다. 미리 보려면 backend 폴더에서 `npm run test -- release-notes`를 돌립니다. 그래도 배포되면 서버는 그 글만 건너뛰고 로그에 파일 이름과 줄 번호를 남깁니다. 서버는 그대로 뜹니다.
- 앱은 옛 판이어도 새 글을 받으니, 앱 이야기를 쓸 때 새 판을 내지 않아도 됩니다.
+3
View File
@@ -18,6 +18,7 @@ import { FileStorageModule } from '@/modules/file-storage/file-storage.module';
import { HealthModule } from '@/modules/health/health.module';
import { MailModule } from '@/modules/mail/mail.module';
import { PushModule } from '@/modules/push/push.module';
import { ReleaseNotesModule } from '@/modules/release-notes/release-notes.module';
import { SystemSettingsModule } from '@/modules/system-settings/system-settings.module';
import { UploadModule } from '@/modules/upload/upload.module';
import { UserModule } from '@/modules/user/user.module';
@@ -89,6 +90,8 @@ const THROTTLE_DEFAULT_LIMIT = 100; // 구간당 IP별 최대 요청 수
AppConfigModule,
// 휴대폰 앱 받기 (사내 배포) — 최신 판 정보 · APK 파일 (로그인 필요). 올리기는 운영 도구다
AppReleasesModule,
// 업데이트 내역 — 목록 · 글 (로그인 필요). 글은 저장소의 backend/release-notes 폴더(글 파일 하나가 글 하나), 올리는 API 는 없다
ReleaseNotesModule,
UploadModule,
UserModule,
],
@@ -0,0 +1,97 @@
import { ApiProperty } from '@nestjs/swagger';
import { PageInfoDto } from '@/common/dto/pagination.dto';
import {
RELEASE_NOTE_HEADING_MAX_LENGTH,
RELEASE_NOTE_ITEM_MAX_LENGTH,
RELEASE_NOTE_TITLE_MAX_LENGTH,
} from '../release-notes.constants';
import type {
ReleaseNoteDetail,
ReleaseNoteSection,
ReleaseNoteSummary,
} from '../release-notes.types';
/** 목록의 한 줄 · 글 화면의 다른 글 줄 (계약 7절 `ReleaseNoteSummary`) */
export class ReleaseNoteSummaryDto implements ReleaseNoteSummary {
@ApiProperty({
description:
'글 id — 글 파일 이름에서 .md 를 뗀 것(`YYYY-MM-DD`, 같은 날 두 번째 글부터 `YYYY-MM-DD-2` …). 글 주소에 쓴다',
example: '2026-11-18',
})
id!: string;
@ApiProperty({
description:
'글의 날짜 — 달력 날짜 글(`YYYY-MM-DD`, 파일 이름에서). 시각이 아니다 — 화면은 시간대를 바꾸지 않고 그대로 쓴다(Date 로 읽지 않는다)',
example: '2026-11-18',
})
date!: string;
@ApiProperty({
description: `제목 — 글 파일의 첫 줄(# 제목), ${RELEASE_NOTE_TITLE_MAX_LENGTH}자까지`,
example: '메일 검색이 빨라지고 휴대폰 앱이 나왔습니다',
})
title!: string;
}
/** GET /api/release-notes 의 응답 */
export class ReleaseNotePageDto {
@ApiProperty({
type: [ReleaseNoteSummaryDto],
description:
'최근 글부터 — 날짜 내림차순, 같은 날은 뒤 번호가 먼저(`2026-11-18-2` → `2026-11-18`)',
})
items!: ReleaseNoteSummaryDto[];
@ApiProperty({ type: PageInfoDto, description: 'total 은 글 수' })
pageInfo!: PageInfoDto;
}
/** 묶음 (계약 7절 `ReleaseNoteSection`) */
export class ReleaseNoteSectionDto implements ReleaseNoteSection {
@ApiProperty({
description: `묶음 머리(## 머리, ${RELEASE_NOTE_HEADING_MAX_LENGTH}자까지) — 머리 없이 쓴 묶음은 null(글의 맨 앞에만 온다)`,
type: String,
nullable: true,
example: '휴대폰 앱',
})
heading!: string | null;
@ApiProperty({
description:
`묶음의 줄(- 한 줄, ${RELEASE_NOTE_ITEM_MAX_LENGTH}자까지 — 하나 이상). 서버가 다듬은 글 그대로(앞뒤 빈칸 지움 · 안의 빈칸 여럿은 하나로 · 이어 쓴 줄은 띄어쓰기 하나로 · NFC). ` +
'마크다운 · HTML 로 해석하지 않고 글로만 그린다',
type: [String],
example: [
'안드로이드 휴대폰 앱이 나왔습니다. 웹메일의 [설정] → [휴대폰 앱]에서 받을 수 있습니다.',
],
})
items!: string[];
}
/** GET /api/release-notes/{id} 의 응답 (계약 7절 `ReleaseNote`) */
export class ReleaseNoteDto
extends ReleaseNoteSummaryDto
implements ReleaseNoteDetail
{
@ApiProperty({
type: [ReleaseNoteSectionDto],
description: '묶음 — 파일에 쓴 차례, 하나 이상',
})
sections!: ReleaseNoteSectionDto[];
@ApiProperty({
description: '이전 글 — 바로 더 옛 글(목록에서 이 글 아래). 없으면 null',
type: ReleaseNoteSummaryDto,
nullable: true,
})
previous!: ReleaseNoteSummaryDto | null;
@ApiProperty({
description: '다음 글 — 바로 더 새 글(목록에서 이 글 위). 없으면 null',
type: ReleaseNoteSummaryDto,
nullable: true,
})
next!: ReleaseNoteSummaryDto | null;
}
@@ -0,0 +1,538 @@
import { startCpuTimer } from '@/../test/cpu-time';
import {
classifyReleaseNoteFile,
compareReleaseNotes,
describeReleaseNoteProblem,
isCalendarDate,
isReleaseNoteId,
parseReleaseNoteText,
type ReleaseNoteProblem,
} from './release-note-format';
import {
RELEASE_NOTE_HEADING_MAX_LENGTH,
RELEASE_NOTE_ITEM_MAX_LENGTH,
RELEASE_NOTE_PROBLEMS_MAX,
RELEASE_NOTE_TITLE_MAX_LENGTH,
} from './release-notes.constants';
/** 줄들을 이어 글 파일의 글로 */
const lines = (...rows: string[]) => rows.join('\n');
/** 맞는 글의 결과 — 틀리면 문제를 보이며 실패한다 */
function parsed(text: string) {
const result = parseReleaseNoteText(text);
if (!result.ok) {
throw new Error(
`틀린 글로 읽었다: ${result.problems.map(describeReleaseNoteProblem).join(', ')}`,
);
}
return result;
}
/** 틀린 글의 문제 — 맞는 글로 읽으면 실패한다 */
function problemsOf(text: string): readonly ReleaseNoteProblem[] {
const result = parseReleaseNoteText(text);
if (result.ok) throw new Error('맞는 글로 읽었다');
return result.problems;
}
const LINE_SEPARATOR = String.fromCharCode(0x2028);
const BYTE_ORDER_MARK = String.fromCharCode(0xfeff);
const BELL = String.fromCharCode(0x07);
/** 이모지 하나 — UTF-16 으로 두 칸 */
const EMOJI = String.fromCodePoint(0x1f600);
describe('parseReleaseNoteText — 맞는 글', () => {
it('보드 C 의 글 — 제목 · 메모 · 머리 · 줄, 빈칸 두 개로 시작한 줄은 앞 줄에 띄어쓰기 하나로 이어 붙는다', () => {
const result = parsed(
lines(
'# 메일 검색이 빨라지고 휴대폰 앱이 나왔습니다',
'<!-- 배포 날짜가 정해지면 파일 이름을 그날로 바꿉니다 -->',
'',
'## 휴대폰 앱',
'- 안드로이드 휴대폰 앱이 나왔습니다. 웹메일의 [설정] → [휴대폰 앱]에서 받을 수 있습니다.',
'- 앱에서 메일을 읽고 쓰고 찾을 수 있고, 새 메일이 오면 휴대폰으로 알려 드립니다.',
' 알림에는 새 메일이 몇 통인지만 나오고, 보낸 사람이나 제목은 나오지 않습니다.',
'',
'## 메일 검색',
'- 찾은 말에는 노란 표시가 붙습니다.',
'',
),
);
expect(result).toEqual({
ok: true,
title: '메일 검색이 빨라지고 휴대폰 앱이 나왔습니다',
sections: [
{
heading: '휴대폰 앱',
items: [
'안드로이드 휴대폰 앱이 나왔습니다. 웹메일의 [설정] → [휴대폰 앱]에서 받을 수 있습니다.',
'앱에서 메일을 읽고 쓰고 찾을 수 있고, 새 메일이 오면 휴대폰으로 알려 드립니다. 알림에는 새 메일이 몇 통인지만 나오고, 보낸 사람이나 제목은 나오지 않습니다.',
],
},
{ heading: '메일 검색', items: ['찾은 말에는 노란 표시가 붙습니다.'] },
],
});
});
it('머리 없이 바로 쓴 줄은 머리 없는 묶음(heading null) — 뒤에 머리가 오면 새 묶음', () => {
expect(
parsed(
lines(
'# 왼쪽 메뉴를 정리했습니다',
'- 아직 준비 중인 [ERP]를 왼쪽 메뉴에서 뺐습니다.',
'## 주소록',
'- 한 줄',
),
).sections,
).toEqual([
{
heading: null,
items: ['아직 준비 중인 [ERP]를 왼쪽 메뉴에서 뺐습니다.'],
},
{ heading: '주소록', items: ['한 줄'] },
]);
});
it('앞뒤 빈칸은 지우고 안의 빈칸 · 탭 여럿은 하나로 — 탭으로 시작한 줄도 이어 쓰기, 굵게 · 링크 꼴은 글 그대로', () => {
const result = parsed(
lines(
'# 제목 ',
'##\t머리\t ',
'- 한 줄\t\t끝 ',
'\t탭으로 이어 씀',
' 빈칸 여럿으로 이어 씀',
'- **굵게** [링크](https://example.com) `코드`',
),
);
expect(result.title).toBe('제목');
expect(result.sections).toEqual([
{
heading: '머리',
items: [
'한 줄 끝 탭으로 이어 씀 빈칸 여럿으로 이어 씀',
'**굵게** [링크](https://example.com) `코드`',
],
},
]);
});
it('빈 줄 · 메모(여러 줄 · 제목 앞 · 줄 사이)는 화면에 없다 — 빈 줄 · 메모를 지나도 이어 쓰기는 앞 - 줄에 붙는다', () => {
const result = parsed(
lines(
'<!-- 맨 앞 메모',
' 여러 줄 -->',
'',
'# 제목',
' <!-- 들여 쓴 메모 -->',
'- 첫 줄',
'<!-- 줄 사이 메모 -->',
'',
' 이어 쓴 글',
'<!-->',
'<!--->',
'- 둘째 줄',
),
);
expect(result).toEqual({
ok: true,
title: '제목',
sections: [{ heading: null, items: ['첫 줄 이어 쓴 글', '둘째 줄'] }],
});
});
it('CRLF 줄 끝 · 앞의 BOM 은 지운다', () => {
const result = parsed(
`${BYTE_ORDER_MARK}# 제목\r\n## 머리\r\n- 한 줄\r\n 이어 씀\r\n`,
);
expect(result.title).toBe('제목');
expect(result.sections).toEqual([
{ heading: '머리', items: ['한 줄 이어 씀'] },
]);
});
it('NFC 로 모은다 — 풀어 쓴 한글(NFD)도 같은 글 · 같은 길이', () => {
const decomposed = '업데이트 내역'.normalize('NFD');
expect(decomposed.length).toBeGreaterThan('업데이트 내역'.length);
const result = parsed(lines(`# ${decomposed}`, `- ${decomposed}`));
expect(result.title).toBe('업데이트 내역');
expect(result.sections[0].items).toEqual(['업데이트 내역']);
});
it('길이 한도 — 제목 100 · 머리 40 · 한 줄 500 까지는 맞다(UTF-16 — 이모지는 두 칸)', () => {
const title = '가'.repeat(RELEASE_NOTE_TITLE_MAX_LENGTH - 2) + EMOJI;
const heading = EMOJI + '나'.repeat(RELEASE_NOTE_HEADING_MAX_LENGTH - 2);
const item = '다'.repeat(RELEASE_NOTE_ITEM_MAX_LENGTH);
const result = parsed(lines(`# ${title}`, `## ${heading}`, `- ${item}`));
expect(result.title).toHaveLength(RELEASE_NOTE_TITLE_MAX_LENGTH);
expect(result.sections[0].heading).toHaveLength(
RELEASE_NOTE_HEADING_MAX_LENGTH,
);
expect(result.sections[0].items[0]).toHaveLength(
RELEASE_NOTE_ITEM_MAX_LENGTH,
);
});
it('- 만 쓴 줄도 이어 쓰기가 글을 채우면 맞다', () => {
expect(parsed(lines('# 제목', '-', ' 이어 쓴 글')).sections).toEqual([
{ heading: null, items: ['이어 쓴 글'] },
]);
});
});
describe('parseReleaseNoteText — 틀린 글 (줄 번호 · 까닭)', () => {
it('첫 글 줄이 # 제목이 아니면 그 줄에 title-missing — 나머지도 읽어 함께 알린다', () => {
expect(problemsOf(lines('', '## 머리', '- 한 줄', '맨 글'))).toEqual([
{ line: 2, reason: 'title-missing' },
{ line: 4, reason: 'unknown-line' },
]);
});
it('빈 파일 · 메모뿐인 파일은 title-missing · no-items (파일 전체 — 줄 번호 없음)', () => {
for (const text of ['', '\n\n', lines('<!-- 메모만', '-->')]) {
expect(problemsOf(text)).toEqual([
{ line: null, reason: 'title-missing' },
{ line: null, reason: 'no-items' },
]);
}
});
it('# 제목을 또 쓰면 title-again, 비면 title-empty, 길면 title-too-long', () => {
expect(problemsOf(lines('# 제목', '- 한 줄', '# 또 제목'))).toEqual([
{ line: 3, reason: 'title-again' },
]);
expect(problemsOf(lines('# ', '- 한 줄'))).toEqual([
{ line: 1, reason: 'title-empty' },
]);
expect(
problemsOf(
lines(
`# ${'가'.repeat(RELEASE_NOTE_TITLE_MAX_LENGTH - 1)}${EMOJI}`,
'- 줄',
),
),
).toEqual([{ line: 1, reason: 'title-too-long' }]);
});
it('머리가 비면 heading-empty, 길면 heading-too-long, 아래에 줄이 없으면 heading-without-items', () => {
expect(
problemsOf(
lines(
'# 제목',
'##',
'- 줄',
`## ${'나'.repeat(RELEASE_NOTE_HEADING_MAX_LENGTH + 1)}`,
'- 줄',
'## 줄 없는 머리',
'',
'## 마지막 머리',
'- 줄',
),
),
).toEqual([
{ line: 2, reason: 'heading-empty' },
{ line: 4, reason: 'heading-too-long' },
{ line: 6, reason: 'heading-without-items' },
]);
});
it('줄이 비면 item-empty, 이어 붙인 뒤 500자를 넘으면 그 줄의 시작에 item-too-long', () => {
const half = '다'.repeat(RELEASE_NOTE_ITEM_MAX_LENGTH / 2);
expect(
problemsOf(
lines('# 제목', '- ', '- 맞는 줄', `- ${half}`, ` ${half}`),
),
).toEqual([
{ line: 2, reason: 'item-empty' },
// 둘을 띄어쓰기 하나로 이으면 501자
{ line: 4, reason: 'item-too-long' },
]);
});
it('글에 줄이 하나도 없으면 no-items', () => {
expect(problemsOf(lines('# 제목', '<!-- 줄은 나중에 -->'))).toEqual([
{ line: null, reason: 'no-items' },
]);
});
it.each([
['맨 글', '그냥 쓴 글'],
['###', '### 작은 머리'],
['별표 목록', '* 별표 줄'],
['더하기 목록', '+ 더하기 줄'],
['번호 목록', '1. 번호 줄'],
['표', '| 칸 | 칸 |'],
['인용', '> 인용'],
['가로줄', '---'],
['한 칸 들여 쓴 줄', ' - 한 칸 들여 쓴 줄'],
['띄어쓰기 없는 머리', '##머리'],
['띄어쓰기 없는 줄', '-줄'],
])('쓸 수 없는 줄(%s)은 unknown-line', (_name, row) => {
expect(problemsOf(lines('# 제목', '- 한 줄', row))).toEqual([
{ line: 3, reason: 'unknown-line' },
]);
});
it('띄어쓰기 없는 제목(#제목)은 제목이 아니다 — 첫 글 줄이면 title-missing · unknown-line', () => {
expect(problemsOf(lines('#제목', '- 한 줄'))).toEqual([
{ line: 1, reason: 'title-missing' },
{ line: 1, reason: 'unknown-line' },
]);
});
it('이어 쓰기 — 앞에 - 줄이 없으면(제목 · 머리 바로 아래) continuation-without-item, 들여 쓴 - 는 nested-list', () => {
expect(
problemsOf(
lines(
'# 제목',
' 제목에 이어 씀',
'## 머리',
' 머리에 이어 씀',
'- 한 줄',
' - 하위 목록',
),
),
).toEqual([
{ line: 2, reason: 'continuation-without-item' },
{ line: 4, reason: 'continuation-without-item' },
{ line: 6, reason: 'nested-list' },
]);
});
it('메모 — 닫지 않으면 시작 줄에 memo-unclosed, 닫은 뒤 같은 줄에 글이 있으면 memo-trailing, 글 안의 <!-- 는 memo-inside-text', () => {
expect(
problemsOf(
lines(
'# 제목 <!-- 제목 메모 -->',
'- 한 줄 <!-- 줄 메모 -->',
'<!-- 메모 --> 뒤에 글',
'<!-- 여러 줄',
'x --> 닫은 뒤 글',
'- 둘째 줄',
'<!-- 닫지 않은 메모',
'- 메모 안이라 읽지 않는 줄',
),
),
).toEqual([
{ line: 1, reason: 'memo-inside-text' },
{ line: 2, reason: 'memo-inside-text' },
{ line: 3, reason: 'memo-trailing' },
{ line: 5, reason: 'memo-trailing' },
{ line: 7, reason: 'memo-unclosed' },
]);
});
it('보이지 않는 글자(제어 문자 · 줄 끝이 아닌 CR · 줄 구분 문자)는 control-character', () => {
expect(
problemsOf(
lines(
`# 제목${BELL}`,
`## 머리\r중간`,
`- 한 줄${LINE_SEPARATOR}둘째`,
'- 맞는 줄',
` 이어 씀${BELL}`,
),
),
).toEqual([
{ line: 1, reason: 'control-character' },
{ line: 2, reason: 'control-character' },
{ line: 3, reason: 'control-character' },
{ line: 5, reason: 'control-character' },
]);
});
it(`문제는 줄 차례로 앞의 ${RELEASE_NOTE_PROBLEMS_MAX}개까지 — 파일 전체의 문제가 먼저`, () => {
const many = Array.from({ length: 300 }, (_, index) => `맨 글 ${index}`);
const problems = problemsOf(lines('# 제목', ...many));
expect(problems).toHaveLength(RELEASE_NOTE_PROBLEMS_MAX);
expect(problems[0]).toEqual({ line: null, reason: 'no-items' });
expect(problems.slice(1)).toEqual(
Array.from({ length: RELEASE_NOTE_PROBLEMS_MAX - 1 }, (_, index) => ({
line: index + 2,
reason: 'unknown-line',
})),
);
});
it('다 읽은 뒤에 찾는 앞쪽 줄의 문제(1행 제목 길이 · 3행 줄 없는 머리)도 뒤에 줄 문제가 많아 앞의 20개에 든다', () => {
const many = Array.from({ length: 500 }, (_, index) => `맨 글 ${index}`);
const problems = problemsOf(
lines(
`# ${'가'.repeat(RELEASE_NOTE_TITLE_MAX_LENGTH + 1)}`,
'- 줄',
'## 줄 없는 머리',
...many,
),
);
expect(problems).toEqual([
{ line: 1, reason: 'title-too-long' },
{ line: 3, reason: 'heading-without-items' },
...Array.from({ length: RELEASE_NOTE_PROBLEMS_MAX - 2 }, (_, index) => ({
line: index + 4,
reason: 'unknown-line',
})),
]);
});
it('문제를 글로 — 줄 번호 · 까닭 · 설명만 (글 내용은 없다)', () => {
expect(
describeReleaseNoteProblem({ line: 12, reason: 'item-too-long' }),
).toBe('12행 item-too-long(한 줄은 500자까지입니다(이어 붙인 뒤))');
expect(describeReleaseNoteProblem({ line: null, reason: 'encoding' })).toBe(
'파일 encoding(UTF-8 글이 아닙니다)',
);
});
});
describe('파일 이름 · id · 날짜', () => {
it('글 파일 — 이름이 id, 앞 10자가 날짜, 같은 날 둘째부터 번호(2~99)', () => {
expect(classifyReleaseNoteFile('2026-11-18.md')).toEqual({
kind: 'note',
id: '2026-11-18',
date: '2026-11-18',
sequence: 1,
});
expect(classifyReleaseNoteFile('2026-11-18-2.md')).toEqual({
kind: 'note',
id: '2026-11-18-2',
date: '2026-11-18',
sequence: 2,
});
expect(classifyReleaseNoteFile('2026-11-18-99.md')).toMatchObject({
kind: 'note',
sequence: 99,
});
expect(classifyReleaseNoteFile('2024-02-29.md')).toMatchObject({
kind: 'note',
date: '2024-02-29',
});
});
it('쓰는 사람용 안내(README.md)는 글이 아니고 문제도 아니다', () => {
expect(classifyReleaseNoteFile('README.md')).toEqual({ kind: 'readme' });
});
it.each([
'2026-11-18-1.md',
'2026-11-18-0.md',
'2026-11-18-02.md',
'2026-11-18-100.md',
'2026-11-18-2-3.md',
'2026-11-18.MD',
'2026-11-18.txt',
'2026-11-18',
'2026-1-18.md',
'20261118.md',
'readme.md',
'notes.md',
'.gitkeep',
'.DS_Store',
])('글 파일 이름이 아니다: %s', (name) => {
expect(classifyReleaseNoteFile(name)).toEqual({
kind: 'not-a-note',
reason: 'file-name',
});
});
it.each([
'2026-02-30.md',
'2026-02-29.md',
'2100-02-29.md',
'2026-13-01.md',
'2026-00-10.md',
'2026-04-31.md',
'2026-11-00.md',
'0000-01-01.md',
])('없는 날짜: %s', (name) => {
expect(classifyReleaseNoteFile(name)).toEqual({
kind: 'not-a-note',
reason: 'date',
});
});
it('있는 날짜 — 윤년(4로 나누어지되 100은 아니고 400은 맞다)', () => {
expect(isCalendarDate(2024, 2, 29)).toBe(true);
expect(isCalendarDate(2000, 2, 29)).toBe(true);
expect(isCalendarDate(1900, 2, 29)).toBe(false);
expect(isCalendarDate(2026, 12, 31)).toBe(true);
expect(isCalendarDate(2026, 6, 31)).toBe(false);
});
it('경로 id 의 모양 — 맞는 모양만, 32자 넘거나 경로 · 확장자 · 다른 숫자 글자가 섞이면 아니다', () => {
for (const id of ['2026-11-18', '2026-11-18-2', '2026-11-18-42']) {
expect(isReleaseNoteId(id)).toBe(true);
}
for (const id of [
'',
'2026-11-18-1',
'2026-11-18.md',
'../2026-11-18',
'2026-11-18/../x',
'2026-11-18 ',
`2026-11-18${'-2'.repeat(20)}`,
'2026-11-18',
'x'.repeat(33),
]) {
expect(isReleaseNoteId(id)).toBe(false);
}
for (const value of [20261118, null, undefined, ['2026-11-18']]) {
expect(isReleaseNoteId(value)).toBe(false);
}
});
it('차례 — 최근 글부터(날짜 내림차순), 같은 날은 뒤 번호가 먼저', () => {
const notes = [
{ id: '2026-11-03', date: '2026-11-03', sequence: 1 },
{ id: '2026-11-18', date: '2026-11-18', sequence: 1 },
{ id: '2026-11-18-10', date: '2026-11-18', sequence: 10 },
{ id: '2025-12-31', date: '2025-12-31', sequence: 1 },
{ id: '2026-11-18-2', date: '2026-11-18', sequence: 2 },
];
expect([...notes].sort(compareReleaseNotes).map((note) => note.id)).toEqual(
[
'2026-11-18-10',
'2026-11-18-2',
'2026-11-18',
'2026-11-03',
'2025-12-31',
],
);
});
});
describe('parseReleaseNoteText — 입력 길이에 비례', () => {
it('20만 줄(이어 쓰기 · 메모 · 줄)과 빈칸이 많은 1MB 한 줄도 금방 끝난다', () => {
const continuation = Array.from({ length: 200_000 }, () => ' 이어 씀');
const memo = Array.from({ length: 200_000 }, () => '메모 안 글');
const items = Array.from(
{ length: 100_000 },
(_, index) => `- 줄 ${index}`,
);
// 배열로 잇는다 — 수십만 줄을 함수 인자로 펼치면 호출 스택이 넘친다
const text = ['# 제목', '- 첫 줄']
.concat(continuation, ['<!-- 긴 메모'], memo, ['-->'], items, [
`- ${'가 \t'.repeat(300_000)}`,
])
.join('\n');
const cpuMs = startCpuTimer();
const result = parseReleaseNoteText(text);
// 지금 Linux 컨테이너에서 수백 ms — 줄마다 앞의 글을 다시 훑는(제곱) 길로 돌아가면 이 한도를 크게 넘는다
expect(cpuMs()).toBeLessThan(5_000);
expect(result.ok).toBe(false);
});
});
@@ -0,0 +1,448 @@
import {
RELEASE_NOTE_FILE_EXTENSION,
RELEASE_NOTE_FILE_MAX_BYTES,
RELEASE_NOTE_FIRST_SEQUENCE,
RELEASE_NOTE_HEADING_MAX_LENGTH,
RELEASE_NOTE_ID_MAX_LENGTH,
RELEASE_NOTE_ID_PATTERN,
RELEASE_NOTE_ITEM_MAX_LENGTH,
RELEASE_NOTE_PROBLEMS_MAX,
RELEASE_NOTE_TITLE_MAX_LENGTH,
RELEASE_NOTES_README,
} from './release-notes.constants';
import type { ReleaseNoteSection } from './release-notes.types';
/**
* 업데이트 내역 글 파일 해석 (순수 함수 — 계약 7절 "글 파일").
*
* 파일 이름 `YYYY-MM-DD.md`(같은 날 둘째부터 `YYYY-MM-DD-2.md` …)가 글 id · 날짜이고, 파일 안은 마크다운의 아주 작은 일부(글만)다:
* 첫 글 줄 `# 제목`(꼭 — 한 번만), `## 머리`(묶음 시작), `- 한 줄`(그 묶음의 줄 — 머리 없이 쓰면 머리 없는 묶음),
* 빈칸 두 개 이상(또는 탭)으로 시작한 줄은 앞 `- ` 줄에 띄어쓰기 하나로 이어 붙인다. 빈 줄 · `<!-- 메모 -->`(여러 줄도)는 어디든 —
* 메모는 줄 앞에서 시작한다(앞 빈칸은 괜찮다 · 글 뒤에 붙이지 않는다).
* 그 밖의 줄(맨 글 · `###` · 표 · 한 칸 들여 쓴 줄 등)은 틀림. 굵게 · 링크 꼴은 해석하지 않는다(글 그대로).
*
* 틀리면 줄 번호를 단 문제 목록을 돌려준다 — 시험(CI)은 실패하고 서버는 그 글만 건너뛴다. 글 내용은 문제에 싣지 않는다(로그에 남는다).
* 입력 길이에 비례한다 — 줄을 한 번씩만 보고, 정규식은 앞부분만 보거나 한 번 훑는다
*/
/** 문제의 까닭 → 쓰는 사람에게 보일 말 (로그 · 시험 출력 — 글 내용은 넣지 않는다) */
export const RELEASE_NOTE_PROBLEM_TEXTS = {
'file-name':
'글 파일 이름이 아닙니다 — YYYY-MM-DD.md 로, 같은 날 두 번째 글부터 YYYY-MM-DD-2.md 로 씁니다',
'not-a-file': '보통 파일이 아닙니다(폴더 · 링크)',
date: '없는 날짜입니다',
'too-large': `파일이 너무 큽니다(${RELEASE_NOTE_FILE_MAX_BYTES / 1024}KB까지)`,
unreadable: '파일을 읽지 못했습니다',
encoding: 'UTF-8 글이 아닙니다',
'title-missing': '첫 줄은 # 제목이어야 합니다',
'title-again': '# 제목은 첫 줄에 한 번만 씁니다',
'title-empty': '제목이 비었습니다',
'title-too-long': `제목은 ${RELEASE_NOTE_TITLE_MAX_LENGTH}자까지입니다`,
'heading-empty': '묶음 머리가 비었습니다',
'heading-too-long': `묶음 머리는 ${RELEASE_NOTE_HEADING_MAX_LENGTH}자까지입니다`,
'heading-without-items': '묶음 머리 아래에 줄(- )이 없습니다',
'item-empty': '줄이 비었습니다',
'item-too-long': `한 줄은 ${RELEASE_NOTE_ITEM_MAX_LENGTH}자까지입니다(이어 붙인 뒤)`,
'continuation-without-item': '이어 붙일 줄(- )이 앞에 없습니다',
'nested-list': '하위 목록은 쓸 수 없습니다',
'unknown-line':
'쓸 수 없는 줄입니다 — # 제목, ## 머리, - 한 줄, 빈칸 두 개로 시작한 이어 쓰기, <!-- 메모 --> 만 씁니다',
'memo-unclosed': '메모(<!--)를 닫지(-->) 않았습니다',
'memo-trailing': '메모를 닫은(-->) 뒤 같은 줄에 글이 있습니다',
'memo-inside-text':
'글 안에 메모(<!--)가 있습니다 — 메모는 글 뒤에 붙이지 않고 줄 앞에서 시작합니다',
'control-character': '보이지 않는 글자(제어 문자)가 있습니다',
'no-items': '글에 줄(- )이 하나도 없습니다',
} as const;
export type ReleaseNoteProblemReason = keyof typeof RELEASE_NOTE_PROBLEM_TEXTS;
/** 글 파일의 문제 하나 — 줄 번호는 1부터, 파일 전체의 문제(이름 · 크기 · 글자 · 줄 없음 등)면 null */
export interface ReleaseNoteProblem {
readonly line: number | null;
readonly reason: ReleaseNoteProblemReason;
}
// ── 파일 이름 · id ──────────────────────────────────────────────────────
/** `YYYY-MM-DD` 의 길이 — id 의 앞부분이 날짜다 */
const DATE_LENGTH = 'YYYY-MM-DD'.length;
const MONTHS_PER_YEAR = 12;
const DAYS_PER_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
const FEBRUARY = 2;
interface ReleaseNoteIdParts {
readonly year: number;
readonly month: number;
readonly day: number;
readonly sequence: number;
}
/** id 를 날짜 · 차례 번호로 — 모양(길이 · 글자)이 틀리면 null. 있는 날짜인지는 보지 않는다 */
function releaseNoteIdParts(id: string): ReleaseNoteIdParts | null {
if (id.length > RELEASE_NOTE_ID_MAX_LENGTH) return null;
const match = RELEASE_NOTE_ID_PATTERN.exec(id);
if (!match) return null;
return {
year: Number(match[1]),
month: Number(match[2]),
day: Number(match[3]),
sequence:
match[4] === undefined ? RELEASE_NOTE_FIRST_SEQUENCE : Number(match[4]),
};
}
/** 경로의 글 id 모양인가 — 길이를 먼저 보고 정규식(앞에서 한 번 훑는다). 맞아도 그런 글이 있는지는 목록이 정한다 */
export function isReleaseNoteId(value: unknown): value is string {
return typeof value === 'string' && releaseNoteIdParts(value) !== null;
}
function isLeapYear(year: number): boolean {
return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
}
/** 있는 날짜인가 (그레고리력 — 2026-02-30 · 2026-13-01 · 0000-01-01 은 없다) */
export function isCalendarDate(
year: number,
month: number,
day: number,
): boolean {
if (!Number.isInteger(year) || year < 1) return false;
if (!Number.isInteger(month) || month < 1 || month > MONTHS_PER_YEAR) {
return false;
}
const days =
month === FEBRUARY && isLeapYear(year)
? DAYS_PER_MONTH[month - 1] + 1
: DAYS_PER_MONTH[month - 1];
return Number.isInteger(day) && day >= 1 && day <= days;
}
/** 글 폴더의 파일 이름을 가른다 — 안내(README) · 글 · 글이 아님(이름 모양 · 없는 날짜) */
export type ReleaseNoteFileKind =
| { readonly kind: 'readme' }
| {
readonly kind: 'note';
readonly id: string;
readonly date: string;
readonly sequence: number;
}
| { readonly kind: 'not-a-note'; readonly reason: 'file-name' | 'date' };
export function classifyReleaseNoteFile(fileName: string): ReleaseNoteFileKind {
if (fileName === RELEASE_NOTES_README) return { kind: 'readme' };
if (!fileName.endsWith(RELEASE_NOTE_FILE_EXTENSION)) {
return { kind: 'not-a-note', reason: 'file-name' };
}
const id = fileName.slice(0, -RELEASE_NOTE_FILE_EXTENSION.length);
const parts = releaseNoteIdParts(id);
if (parts === null) return { kind: 'not-a-note', reason: 'file-name' };
if (!isCalendarDate(parts.year, parts.month, parts.day)) {
return { kind: 'not-a-note', reason: 'date' };
}
return {
kind: 'note',
id,
date: id.slice(0, DATE_LENGTH),
sequence: parts.sequence,
};
}
/** 목록 차례 — 최근 글부터: 날짜 내림차순, 같은 날은 뒤 번호가 먼저(`2026-11-18-2` → `2026-11-18`) */
export function compareReleaseNotes(
a: { readonly date: string; readonly sequence: number },
b: { readonly date: string; readonly sequence: number },
): number {
if (a.date !== b.date) return a.date < b.date ? 1 : -1;
return b.sequence - a.sequence;
}
// ── 파일 안 ─────────────────────────────────────────────────────────────
/** 줄의 꼴 — 앞부분만 본다(빈칸 두 개 · 탭으로 시작하면 이어 쓰기, `###` 은 쓰지 않는 줄) */
const CONTINUATION_LINE = /^(?:\t|[ \t]{2})/;
const DEEPER_HEADING_LINE = /^###/;
const HEADING_LINE = /^##(?:[ \t]|$)/;
const TITLE_LINE = /^#(?:[ \t]|$)/;
const ITEM_LINE = /^-(?:[ \t]|$)/;
const HEADING_MARKER_LENGTH = '##'.length;
const TITLE_MARKER_LENGTH = '#'.length;
const ITEM_MARKER_LENGTH = '-'.length;
const MEMO_START = '<!--';
const MEMO_END = '-->';
/** `<!-->` · `<!--->` 도 닫힌 메모다(HTML 과 같다) — 닫는 표시를 여는 표시의 셋째 글자부터 찾는다 */
const MEMO_END_SEARCH_OFFSET = 2;
/** 보이지 않는 글자 — C0(탭 빼고) · DEL · C1(아래 줄 · 문단 구분 문자와 함께). 화면에 글로 나가지 않게 틀린 글로 본다 */
const CONTROL_CHARACTER =
// eslint-disable-next-line no-control-regex
/[\u0000-\u0008\u000a-\u001f\u007f-\u009f]/;
/**
* 줄 · 문단 구분 문자(U+2028 · U+2029) — 보이지 않는 글자와 같이 본다.
* 글자 코드로 만든다 — 그 글자가 정규식 리터럴에 그대로 들어가면 줄 끝이라 문법 오류다(이스케이프가 풀린 채 저장되면)
*/
const LINE_SEPARATORS = [0x2028, 0x2029].map((code) =>
String.fromCharCode(code),
);
/** 보이지 않는 글자가 있는가 — 한 번씩 훑는다 */
function hasInvisibleCharacter(text: string): boolean {
return (
CONTROL_CHARACTER.test(text) ||
LINE_SEPARATORS.some((separator) => text.includes(separator))
);
}
/** 안의 빈칸 · 탭 여럿 — 하나로 (웹 · 앱이 같은 글을 그리게) */
const INNER_BLANKS = /[ \t]+/g;
/** 글 앞의 BOM(U+FEFF) — 지운다 (UTF-8 풀기가 이미 지우지만 해석만 따로 부를 때도) */
const BYTE_ORDER_MARK = String.fromCharCode(0xfeff);
const CARRIAGE_RETURN = '\r';
type LineKind = 'title' | 'heading' | 'item' | 'continuation' | 'other';
function lineKindOf(line: string): LineKind {
if (CONTINUATION_LINE.test(line)) return 'continuation';
if (DEEPER_HEADING_LINE.test(line)) return 'other';
if (HEADING_LINE.test(line)) return 'heading';
if (TITLE_LINE.test(line)) return 'title';
if (ITEM_LINE.test(line)) return 'item';
return 'other';
}
/** 화면에 나갈 글로 다듬는다 — NFC · 안의 빈칸 · 탭 여럿은 하나로 · 앞뒤 빈칸 지움 */
function tidy(text: string): string {
return text.normalize('NFC').replace(INNER_BLANKS, ' ').trim();
}
interface DraftItem {
readonly line: number;
readonly parts: string[];
}
interface DraftSection {
/** `## 머리` 의 줄 — 머리 없는 묶음은 null */
readonly line: number | null;
readonly heading: string | null;
readonly items: DraftItem[];
}
/** 해석 결과 — 맞으면 제목 · 묶음(다듬은 글), 틀리면 줄 차례의 문제(앞의 RELEASE_NOTE_PROBLEMS_MAX 개) */
export type ReleaseNoteTextParse =
| {
readonly ok: true;
readonly title: string;
readonly sections: readonly ReleaseNoteSection[];
}
| { readonly ok: false; readonly problems: readonly ReleaseNoteProblem[] };
/** 모아 둔 문제 하나와 차례 키(줄 번호 — 파일 전체의 문제는 0) */
interface KeptProblem {
readonly problem: ReleaseNoteProblem;
readonly order: number;
}
/**
* 문제 모음 — 줄 차례(파일 전체의 문제가 먼저 · 같은 줄은 찾은 차례)로 앞의 RELEASE_NOTE_PROBLEMS_MAX 개만 늘 지킨다.
* 다 읽은 뒤에 더하는 앞쪽 줄의 문제(1행 제목 길이 · 머리 아래 줄 없음 등)도 그 자리에 들어가고 뒤의 것이 밀려난다.
* 메모리는 상한만큼 — 쓰레기 파일이어도 일정하다. 상한이 작아(20) 자리를 뒤에서부터 훑어도 문제마다 일정한 비용이다
*/
class ProblemList {
private readonly kept: KeptProblem[] = [];
private count = 0;
/** 문제가 하나라도 있었나 (지킨 것 밖으로 밀려난 것까지) */
get hasProblems(): boolean {
return this.count > 0;
}
add(line: number | null, reason: ReleaseNoteProblemReason): void {
this.count += 1;
const order = line ?? 0;
// 같은 줄은 먼저 찾은 것이 앞 — 새 것은 차례 키가 더 작을 때만 앞으로 간다
let index = this.kept.length;
while (index > 0 && order < this.kept[index - 1].order) index -= 1;
if (index >= RELEASE_NOTE_PROBLEMS_MAX) return;
this.kept.splice(index, 0, { problem: { line, reason }, order });
if (this.kept.length > RELEASE_NOTE_PROBLEMS_MAX) this.kept.pop();
}
/** 글 조각(제목 · 머리 · 줄)에 메모 · 보이지 않는 글자가 없는지 */
checkText(text: string, line: number): void {
if (text.includes(MEMO_START)) this.add(line, 'memo-inside-text');
if (hasInvisibleCharacter(text)) this.add(line, 'control-character');
}
/** 줄 차례로 앞의 RELEASE_NOTE_PROBLEMS_MAX 개 */
sorted(): ReleaseNoteProblem[] {
return this.kept.map((entry) => entry.problem);
}
}
/** 한 줄씩 읽으며 제목 · 묶음 · 줄을 모은다 (다듬기 · 길이는 다 읽은 뒤) */
class ReleaseNoteReader {
readonly problems = new ProblemList();
readonly sections: DraftSection[] = [];
/** 제목 — 첫 글 줄이 `# ` 가 아니면 null */
title: { readonly text: string; readonly line: number } | null = null;
/** 글 줄(빈 줄 · 메모 밖)을 하나라도 보았나 — 첫 글 줄이 제목이어야 한다 */
private sawContent = false;
private section: DraftSection | null = null;
/** 이어 쓰기가 붙을 줄 — 머리 · 제목 줄에서 끊긴다(빈 줄 · 메모는 끊지 않는다) */
private item: DraftItem | null = null;
/** 열린 메모의 시작 줄 */
private memoLine: number | null = null;
read(text: string): void {
const source = text.startsWith(BYTE_ORDER_MARK) ? text.slice(1) : text;
const lines = source.split('\n');
for (let index = 0; index < lines.length; index += 1) {
const raw = lines[index];
const line = raw.endsWith(CARRIAGE_RETURN) ? raw.slice(0, -1) : raw;
this.readLine(line, index + 1);
}
if (this.memoLine !== null)
this.problems.add(this.memoLine, 'memo-unclosed');
if (!this.sawContent) this.problems.add(null, 'title-missing');
}
private readLine(line: string, lineNumber: number): void {
if (this.memoLine !== null) {
const end = line.indexOf(MEMO_END);
if (end < 0) return;
this.memoLine = null;
this.checkAfterMemo(line, end, lineNumber);
return;
}
const trimmed = line.trim();
if (trimmed === '') return;
if (trimmed.startsWith(MEMO_START)) {
const start = line.indexOf(MEMO_START);
const end = line.indexOf(MEMO_END, start + MEMO_END_SEARCH_OFFSET);
if (end < 0) {
this.memoLine = lineNumber;
return;
}
this.checkAfterMemo(line, end, lineNumber);
return;
}
const kind = lineKindOf(line);
if (!this.sawContent) {
this.sawContent = true;
if (kind === 'title') {
const text = line.slice(TITLE_MARKER_LENGTH);
this.problems.checkText(text, lineNumber);
this.title = { text, line: lineNumber };
return;
}
this.problems.add(lineNumber, 'title-missing');
}
switch (kind) {
case 'title':
this.problems.add(lineNumber, 'title-again');
this.item = null;
return;
case 'heading': {
const heading = line.slice(HEADING_MARKER_LENGTH);
this.problems.checkText(heading, lineNumber);
this.section = { line: lineNumber, heading, items: [] };
this.sections.push(this.section);
this.item = null;
return;
}
case 'item': {
const text = line.slice(ITEM_MARKER_LENGTH);
this.problems.checkText(text, lineNumber);
if (this.section === null) {
// 머리 없이 바로 쓴 줄 — 머리 없는 묶음 (글의 맨 앞에만 생긴다)
this.section = { line: null, heading: null, items: [] };
this.sections.push(this.section);
}
this.item = { line: lineNumber, parts: [text.trim()] };
this.section.items.push(this.item);
return;
}
case 'continuation':
if (this.item === null) {
this.problems.add(lineNumber, 'continuation-without-item');
} else if (ITEM_LINE.test(trimmed)) {
// 들여 쓴 `- ` — 마크다운에서는 하위 목록이지만 화면은 한 단계만 그린다
this.problems.add(lineNumber, 'nested-list');
} else {
this.problems.checkText(trimmed, lineNumber);
this.item.parts.push(trimmed);
}
return;
case 'other':
this.problems.add(lineNumber, 'unknown-line');
return;
}
}
/** 메모를 닫은 뒤 같은 줄에는 빈칸만 */
private checkAfterMemo(line: string, end: number, lineNumber: number): void {
if (line.slice(end + MEMO_END.length).trim() !== '') {
this.problems.add(lineNumber, 'memo-trailing');
}
}
}
/**
* 글 파일의 글(UTF-8 을 푼 것)을 해석한다 — 앞의 BOM · 줄 끝의 CR 은 지운다.
* 맞으면 다듬은 제목 · 묶음(파일에 쓴 차례), 틀리면 줄 번호를 단 문제(줄 차례로 앞의 RELEASE_NOTE_PROBLEMS_MAX 개)
*/
export function parseReleaseNoteText(text: string): ReleaseNoteTextParse {
const reader = new ReleaseNoteReader();
reader.read(text);
const { problems } = reader;
let title = '';
if (reader.title !== null) {
title = tidy(reader.title.text);
if (title === '') problems.add(reader.title.line, 'title-empty');
if (title.length > RELEASE_NOTE_TITLE_MAX_LENGTH) {
problems.add(reader.title.line, 'title-too-long');
}
}
let itemCount = 0;
const sections = reader.sections.map((draft): ReleaseNoteSection => {
let heading: string | null = null;
if (draft.heading !== null && draft.line !== null) {
heading = tidy(draft.heading);
if (heading === '') problems.add(draft.line, 'heading-empty');
if (heading.length > RELEASE_NOTE_HEADING_MAX_LENGTH) {
problems.add(draft.line, 'heading-too-long');
}
if (draft.items.length === 0) {
problems.add(draft.line, 'heading-without-items');
}
}
const items = draft.items.map((item) => {
const text = tidy(item.parts.join(' '));
if (text === '') problems.add(item.line, 'item-empty');
if (text.length > RELEASE_NOTE_ITEM_MAX_LENGTH) {
problems.add(item.line, 'item-too-long');
}
return text;
});
itemCount += items.length;
return { heading, items };
});
if (itemCount === 0) problems.add(null, 'no-items');
if (problems.hasProblems) {
return { ok: false, problems: problems.sorted() };
}
return { ok: true, title, sections };
}
/** 문제 하나를 로그 · 시험 출력용 글로 — `12행 item-too-long(한 줄은 500자까지입니다(이어 붙인 뒤))` (글 내용은 없다) */
export function describeReleaseNoteProblem(
problem: ReleaseNoteProblem,
): string {
const where = problem.line === null ? '파일' : `${problem.line}행`;
return `${where} ${problem.reason}(${RELEASE_NOTE_PROBLEM_TEXTS[problem.reason]})`;
}
@@ -0,0 +1,34 @@
import { BusinessException } from '@/common/errors/business.exception';
import { ReleaseNoteIdPipe } from './release-note-id.pipe';
describe('ReleaseNoteIdPipe — 경로의 글 id', () => {
const pipe = new ReleaseNoteIdPipe();
it('글 id 모양이면 그대로', () => {
for (const id of ['2026-11-18', '2026-11-18-2', '2026-11-18-99']) {
expect(pipe.transform(id)).toBe(id);
}
});
it('모양이 틀리면 400 이 아니라 404 RES_001 — 없는 글과 같다(경로로 쓰지 않는다)', () => {
for (const value of [
'',
'latest',
'2026-11-18-1',
'2026-11-18.md',
'../../etc/passwd',
'..%2F2026-11-18',
'x'.repeat(33),
42,
undefined,
]) {
let code: string | undefined;
try {
pipe.transform(value);
} catch (error) {
if (error instanceof BusinessException) code = error.code;
}
expect(code).toBe('RES_001');
}
});
});
@@ -0,0 +1,19 @@
import { Injectable, PipeTransform } from '@nestjs/common';
import { BusinessException } from '@/common/errors/business.exception';
import { isReleaseNoteId } from './release-note-format';
/**
* 경로의 업데이트 내역 글 id (`/api/release-notes/{id}`) — `YYYY-MM-DD` · `YYYY-MM-DD-N`(N 은 2~99) · 32자까지.
* 모양이 틀린 id 도 "그런 글이 없다"와 같으므로 400 이 아니라 404 RES_001 이다(손으로 고친 주소도 화면은 "없는 글").
* 파일 경로로 쓰지 않는다 — 서비스가 읽어 둔 목록에서 찾는다(경로 탈출 없음)
*/
@Injectable()
export class ReleaseNoteIdPipe implements PipeTransform<unknown, string> {
transform(value: unknown): string {
if (isReleaseNoteId(value)) return value;
throw new BusinessException('RES_001', {
context: { reason: 'malformed-release-note-id' },
});
}
}
@@ -0,0 +1,347 @@
import { Logger } from '@nestjs/common';
import {
mkdirSync,
mkdtempSync,
rmSync,
statSync,
symlinkSync,
unlinkSync,
utimesSync,
writeFileSync,
} from 'fs';
import { open } from 'fs/promises';
import { tmpdir } from 'os';
import { join } from 'path';
import { RELEASE_NOTE_FILE_MAX_BYTES } from './release-notes.constants';
import {
ReleaseNotesFolder,
readReleaseNoteFile,
readUpTo,
} from './release-notes-folder';
/** 맞는 글 하나 — 제목 · 줄 */
const note = (title: string, item = '한 줄') => `# ${title}\n- ${item}\n`;
describe('ReleaseNotesFolder — 글 폴더 읽기', () => {
let dir: string;
let errorLog: jest.SpyInstance;
let warnLog: jest.SpyInstance;
const pathOf = (name: string) => join(dir, name);
const write = (name: string, text: string | Buffer) =>
writeFileSync(pathOf(name), text);
/** 바뀐 시각을 옮긴다 (초) — 같은 크기로 고친 파일을 "바뀌었다"고 알리게 */
const touch = (name: string, seconds: number) => {
const { atime, mtime } = statSync(pathOf(name));
utimesSync(
pathOf(name),
atime,
new Date(mtime.getTime() + seconds * 1_000),
);
};
const loggedText = (spy: jest.SpyInstance) =>
spy.mock.calls.map((call: unknown[]) => String(call[0])).join('\n');
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'arasystem-release-notes-'));
errorLog = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined);
warnLog = jest
.spyOn(Logger.prototype, 'warn')
.mockImplementation(() => undefined);
});
afterEach(() => {
jest.restoreAllMocks();
rmSync(dir, { recursive: true, force: true });
});
it('글 파일만 최근 글부터 읽는다 — 쓰는 사람용 안내(README.md)는 건너뛰고 로그도 없다', async () => {
write('README.md', '# 안내\n아무 글이나 써도 된다\n');
write('2026-11-05.md', note('화면의 안내 글을 다듬었습니다'));
write('2026-11-18.md', note('휴대폰 앱이 나왔습니다'));
write('2026-11-18-2.md', note('같은 날 두 번째 글'));
const snapshot = await new ReleaseNotesFolder(dir).read();
expect(snapshot.problems).toEqual([]);
expect(snapshot.notes).toEqual([
{
id: '2026-11-18-2',
date: '2026-11-18',
sequence: 2,
title: '같은 날 두 번째 글',
sections: [{ heading: null, items: ['한 줄'] }],
},
{
id: '2026-11-18',
date: '2026-11-18',
sequence: 1,
title: '휴대폰 앱이 나왔습니다',
sections: [{ heading: null, items: ['한 줄'] }],
},
{
id: '2026-11-05',
date: '2026-11-05',
sequence: 1,
title: '화면의 안내 글을 다듬었습니다',
sections: [{ heading: null, items: ['한 줄'] }],
},
]);
expect(errorLog).not.toHaveBeenCalled();
expect(warnLog).not.toHaveBeenCalled();
});
it('이름 · 크기 · 바뀐 시각이 같으면 앞서 읽은 결과를 쓴다 — 하나라도 바뀌면 그 파일을 다시 읽는다', async () => {
// 바뀐 시각을 초 단위의 같은 값으로 둔다 — 파일 시스템의 ns 시각을 그대로 되돌리지는 못한다
const fixed = new Date('2026-11-18T00:00:00.000Z');
const folder = new ReleaseNotesFolder(dir);
write('2026-11-18.md', note('가나다'));
utimesSync(pathOf('2026-11-18.md'), fixed, fixed);
const first = await folder.read();
// 아무것도 바뀌지 않았다 — 앞의 결과 그대로
expect(await folder.read()).toBe(first);
// 같은 크기 · 같은 바뀐 시각으로 글만 고치면 알아채지 못한다 — 이름 · 크기 · 시각만 본다는 증거
write('2026-11-18.md', note('가나라'));
utimesSync(pathOf('2026-11-18.md'), fixed, fixed);
expect((await folder.read()).notes[0].title).toBe('가나다');
// 바뀐 시각이 다르면 다시 읽는다
touch('2026-11-18.md', 10);
expect((await folder.read()).notes[0].title).toBe('가나라');
// 새 파일 · 지운 파일
write('2026-11-19.md', note('새 글'));
expect((await folder.read()).notes.map((item) => item.id)).toEqual([
'2026-11-19',
'2026-11-18',
]);
unlinkSync(pathOf('2026-11-18.md'));
expect((await folder.read()).notes.map((item) => item.id)).toEqual([
'2026-11-19',
]);
});
it('틀린 글 · 글이 아닌 파일 · 폴더는 건너뛰고 바뀔 때마다 error 로그 한 번 — 파일 이름 · 줄 번호 · 까닭만(글 내용은 없다)', async () => {
const folder = new ReleaseNotesFolder(dir);
write('2026-11-18.md', note('맞는 글'));
write('2026-11-19.md', '# 틀린 글\n비밀스러운 맨 글\n- 한 줄\n');
write('notes.txt', '메모');
write('2026-02-30.md', note('없는 날짜'));
mkdirSync(pathOf('2026-11-20.md'));
const snapshot = await folder.read();
expect(snapshot.notes.map((item) => item.id)).toEqual(['2026-11-18']);
expect(snapshot.problems).toEqual([
{ file: '2026-02-30.md', line: null, reason: 'date' },
{ file: '2026-11-19.md', line: 2, reason: 'unknown-line' },
{ file: '2026-11-20.md', line: null, reason: 'not-a-file' },
{ file: 'notes.txt', line: null, reason: 'file-name' },
]);
expect(errorLog).toHaveBeenCalledTimes(4);
const logged = loggedText(errorLog);
expect(logged).toContain('file="2026-11-19.md"');
expect(logged).toContain('2행 unknown-line');
expect(logged).toContain('[SYS_001]');
expect(logged).not.toContain('비밀스러운');
expect(logged).not.toContain('틀린 글');
// 그대로면 다시 남기지 않는다
await folder.read();
expect(errorLog).toHaveBeenCalledTimes(4);
// 그 파일이 바뀌면 그 파일만 다시 읽고 다시 남긴다
touch('2026-11-19.md', 10);
await folder.read();
expect(errorLog).toHaveBeenCalledTimes(5);
// 고치면 목록에 들어온다
write('2026-11-19.md', note('고친 글'));
touch('2026-11-19.md', 20);
expect((await folder.read()).notes.map((item) => item.id)).toEqual([
'2026-11-19',
'2026-11-18',
]);
expect(errorLog).toHaveBeenCalledTimes(5);
});
it('UTF-8 이 아닌 글은 encoding, 너무 큰 파일은 읽지 않고 too-large', async () => {
// EUC-KR 의 "가"(0xB0 0xA1) — UTF-8 로는 틀린 바이트
write('2026-11-18.md', Buffer.from([0x23, 0x20, 0xb0, 0xa1, 0x0a]));
write(
'2026-11-19.md',
`# 큰 글\n- ${'가'.repeat(RELEASE_NOTE_FILE_MAX_BYTES)}\n`,
);
const snapshot = await new ReleaseNotesFolder(dir).read();
expect(snapshot.notes).toEqual([]);
expect(snapshot.problems).toEqual([
{ file: '2026-11-18.md', line: null, reason: 'encoding' },
{ file: '2026-11-19.md', line: null, reason: 'too-large' },
]);
});
it('폴더가 없으면 빈 목록 + warn 한 번 — 생기면 읽고, 또 없어지면 다시 warn', async () => {
const missing = join(dir, 'release-notes');
const folder = new ReleaseNotesFolder(missing);
expect(await folder.read()).toEqual({ notes: [], problems: [] });
expect(await folder.read()).toEqual({ notes: [], problems: [] });
expect(warnLog).toHaveBeenCalledTimes(1);
expect(loggedText(warnLog)).toContain(missing);
mkdirSync(missing);
writeFileSync(join(missing, '2026-11-18.md'), note('생긴 글'));
expect((await folder.read()).notes.map((item) => item.id)).toEqual([
'2026-11-18',
]);
rmSync(missing, { recursive: true });
expect(await folder.read()).toEqual({ notes: [], problems: [] });
expect(warnLog).toHaveBeenCalledTimes(2);
expect(errorLog).not.toHaveBeenCalled();
});
it('함께 온 요청은 한 번 읽은 결과를 나눈다 — 틀린 글의 로그도 한 번', async () => {
write('2026-11-18.md', note('맞는 글'));
write('2026-11-19.md', '맨 글\n');
const folder = new ReleaseNotesFolder(dir);
const [a, b, c] = await Promise.all([
folder.read(),
folder.read(),
folder.read(),
]);
expect(b).toBe(a);
expect(c).toBe(a);
expect(errorLog).toHaveBeenCalledTimes(1);
});
it('링크로 둔 글 파일은 따라가지 않고 not-a-file (링크를 만들 수 없는 곳에서는 건너뛴다)', async () => {
const outside = mkdtempSync(join(tmpdir(), 'arasystem-release-outside-'));
try {
writeFileSync(join(outside, 'target.md'), note('폴더 밖의 글'));
if (!trySymlink(join(outside, 'target.md'), pathOf('2026-11-21.md'))) {
return;
}
write('2026-11-18.md', note('맞는 글'));
const snapshot = await new ReleaseNotesFolder(dir).read();
expect(snapshot.notes.map((item) => item.id)).toEqual(['2026-11-18']);
expect(snapshot.problems).toEqual([
{ file: '2026-11-21.md', line: null, reason: 'not-a-file' },
]);
expect(await readReleaseNoteFile(pathOf('2026-11-21.md'))).toEqual(
// Windows 에는 O_NOFOLLOW 가 없다 — 그곳에서 링크를 만들 수 있었으면 여기서 따라간다(Linux · CI 가 본다)
process.platform === 'win32'
? expect.objectContaining({ ok: true })
: { ok: false, reason: 'not-a-file' },
);
} finally {
rmSync(outside, { recursive: true, force: true });
}
});
it('기동 때 한 번 읽어 틀린 글을 바로 로그에 남긴다 — 읽지 못해도 던지지 않는다', async () => {
write('2026-11-19.md', '맨 글\n');
const folder = new ReleaseNotesFolder(dir);
await folder.onApplicationBootstrap();
expect(errorLog).toHaveBeenCalledTimes(1);
expect(loggedText(errorLog)).toContain('file="2026-11-19.md"');
// 요청 때는 기동 때 읽은 결과 그대로 — 다시 남기지 않는다
await folder.read();
expect(errorLog).toHaveBeenCalledTimes(1);
jest.spyOn(folder, 'read').mockRejectedValue(new Error('EACCES 흉내'));
await expect(folder.onApplicationBootstrap()).resolves.toBeUndefined();
expect(errorLog).toHaveBeenCalledTimes(2);
expect(loggedText(errorLog)).toContain('기동 때 읽지 못했습니다');
});
});
/** 링크를 만든다 — 권한이 없는 곳(Windows 의 보통 계정)에서는 false */
function trySymlink(target: string, path: string): boolean {
try {
symlinkSync(target, path);
return true;
} catch {
return false;
}
}
describe('readReleaseNoteFile — 한 번 열어 그 파일로만 본다', () => {
let dir: string;
const pathOf = (name: string) => join(dir, name);
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'arasystem-release-file-'));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
it('일반 파일은 바이트 그대로', async () => {
writeFileSync(pathOf('2026-11-18.md'), note('글'));
const read = await readReleaseNoteFile(pathOf('2026-11-18.md'));
expect(read).toEqual({ ok: true, bytes: Buffer.from(note('글')) });
});
it('폴더는 not-a-file, 없는 파일은 unreadable', async () => {
mkdirSync(pathOf('2026-11-19.md'));
expect(await readReleaseNoteFile(pathOf('2026-11-19.md'))).toEqual({
ok: false,
reason: 'not-a-file',
});
expect(await readReleaseNoteFile(pathOf('2026-11-20.md'))).toEqual({
ok: false,
reason: 'unreadable',
});
});
it('크기 한도를 넘으면 연 파일의 크기(fstat)로 too-large — 한도 그대로는 읽는다', async () => {
writeFileSync(
pathOf('large.md'),
Buffer.alloc(RELEASE_NOTE_FILE_MAX_BYTES + 1, 0x61),
);
writeFileSync(
pathOf('limit.md'),
Buffer.alloc(RELEASE_NOTE_FILE_MAX_BYTES, 0x61),
);
expect(await readReleaseNoteFile(pathOf('large.md'))).toEqual({
ok: false,
reason: 'too-large',
});
const limit = await readReleaseNoteFile(pathOf('limit.md'));
expect(limit.ok && limit.bytes.length).toBe(RELEASE_NOTE_FILE_MAX_BYTES);
});
it('readUpTo 는 정한 바이트까지만 읽는다 — 그사이 커진 파일도 끝까지 읽지 않는다', async () => {
writeFileSync(
pathOf('grown.md'),
Buffer.alloc(RELEASE_NOTE_FILE_MAX_BYTES * 2, 0x61),
);
const handle = await open(pathOf('grown.md'), 'r');
try {
const bytes = await readUpTo(handle, RELEASE_NOTE_FILE_MAX_BYTES + 1);
expect(bytes.length).toBe(RELEASE_NOTE_FILE_MAX_BYTES + 1);
expect((await readUpTo(handle, 10)).toString()).toBe('a'.repeat(10));
} finally {
await handle.close();
}
});
});
@@ -0,0 +1,333 @@
import { Logger, type OnApplicationBootstrap } from '@nestjs/common';
import { constants } from 'fs';
import { lstat, open, readdir, type FileHandle } from 'fs/promises';
import { join } from 'path';
import { traceTag } from '@/shared/logger/request-context';
import {
classifyReleaseNoteFile,
compareReleaseNotes,
describeReleaseNoteProblem,
parseReleaseNoteText,
type ReleaseNoteProblem,
type ReleaseNoteProblemReason,
} from './release-note-format';
import {
RELEASE_NOTE_FILE_MAX_BYTES,
RELEASE_NOTES_DIR,
} from './release-notes.constants';
import type { ReleaseNote } from './release-notes.types';
/** 건너뛴 파일의 문제 하나 — 파일 이름 · 줄 번호(파일 전체면 null) · 까닭. 글 내용은 없다 */
export interface ReleaseNoteFileProblem {
readonly file: string;
readonly line: number | null;
readonly reason: ReleaseNoteProblemReason;
}
/** 폴더를 읽은 결과 — 글(최근 글부터)과 건너뛴 파일의 문제(파일 이름 차례) */
export interface ReleaseNotesSnapshot {
readonly notes: readonly ReleaseNote[];
readonly problems: readonly ReleaseNoteFileProblem[];
}
const EMPTY_SNAPSHOT: ReleaseNotesSnapshot = Object.freeze({
notes: Object.freeze([]),
problems: Object.freeze([]),
});
/** 폴더에서 본 이름 하나 — 이름 · 보통 파일인지 · 크기 · 바뀐 시각(ms) */
interface FolderEntry {
readonly name: string;
readonly isFile: boolean;
readonly size: number;
readonly mtimeMs: number;
}
/** 이름 하나를 읽은 결과 — 글 · 안내(README — 건너뜀) · 문제 */
type EntryOutcome =
| { readonly kind: 'note'; readonly note: ReleaseNote }
| { readonly kind: 'skipped' }
| {
readonly kind: 'problems';
readonly problems: readonly ReleaseNoteProblem[];
};
interface CachedEntry {
readonly entry: FolderEntry;
readonly outcome: EntryOutcome;
}
/** UTF-8 만 — 틀린 바이트가 있으면 던진다(EUC-KR 로 저장한 글을 깨진 글로 내보내지 않게). 앞의 BOM 은 지운다 */
const UTF8 = new TextDecoder('utf-8', { fatal: true });
/**
* 글 파일 열기 — 링크를 따라가지 않고(O_NOFOLLOW — 그 경로가 링크면 ELOOP), 파이프 · 장치를 열 때 멈추지 않게(O_NONBLOCK — 일반 파일 읽기에는 영향이 없다).
* 일반 파일인지 · 크기는 연 뒤 fstat 으로 본다. Windows 에는 두 표시가 없다(0) — modules/app-releases/apk-file.ts 와 같다
*/
const OPEN_FLAGS =
constants.O_RDONLY |
(constants.O_NOFOLLOW ?? 0) |
(constants.O_NONBLOCK ?? 0);
function errorCodeOf(error: unknown): string | undefined {
return (error as NodeJS.ErrnoException | null)?.code;
}
function reasonOf(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
/** 글 파일 하나를 읽은 결과 — 바이트, 또는 글로 읽지 않는 까닭 */
export type ReleaseNoteFileRead =
| { readonly ok: true; readonly bytes: Buffer }
| {
readonly ok: false;
readonly reason: 'not-a-file' | 'too-large' | 'unreadable';
};
/** 연 파일의 처음부터 `limit` 바이트까지만 읽는다 — 더 길어도 거기서 멈춘다(넘었는지는 부르는 쪽이 길이로 본다) */
export async function readUpTo(
handle: FileHandle,
limit: number,
): Promise<Buffer> {
const buffer = Buffer.alloc(limit);
let filled = 0;
while (filled < limit) {
const { bytesRead } = await handle.read(
buffer,
filled,
limit - filled,
filled,
);
if (bytesRead === 0) break;
filled += bytesRead;
}
return buffer.subarray(0, filled);
}
/** 열지 못한 까닭 — 링크(ELOOP) · 폴더 · 특수 파일은 글이 아니고, 그 밖(권한 · 그사이 지워짐 등)은 읽지 못함 */
function openFailureReason(error: unknown): 'not-a-file' | 'unreadable' {
switch (errorCodeOf(error)) {
case 'ELOOP':
case 'EISDIR':
case 'ENXIO':
return 'not-a-file';
default:
return 'unreadable';
}
}
/**
* 글 파일을 한 번 열어(OPEN_FLAGS) 그 열린 파일로만 본다 — 일반 파일인지 · 크기(fstat), 그리고 크기 한도 + 1 바이트까지만 읽는다.
* 이름을 본(lstat) 뒤 같은 경로가 링크로 바뀌어도 따라가지 않고, 그사이 커진 파일도 끝까지 읽지 않는다
*/
export async function readReleaseNoteFile(
path: string,
): Promise<ReleaseNoteFileRead> {
let handle: FileHandle;
try {
handle = await open(path, OPEN_FLAGS);
} catch (error) {
return { ok: false, reason: openFailureReason(error) };
}
try {
const stats = await handle.stat();
if (!stats.isFile()) return { ok: false, reason: 'not-a-file' };
if (stats.size > RELEASE_NOTE_FILE_MAX_BYTES) {
return { ok: false, reason: 'too-large' };
}
const bytes = await readUpTo(handle, RELEASE_NOTE_FILE_MAX_BYTES + 1);
if (bytes.length > RELEASE_NOTE_FILE_MAX_BYTES) {
return { ok: false, reason: 'too-large' };
}
return { ok: true, bytes };
} catch {
return { ok: false, reason: 'unreadable' };
} finally {
await handle.close();
}
}
function problemsOf(reason: ReleaseNoteProblemReason): EntryOutcome {
return { kind: 'problems', problems: [{ line: null, reason }] };
}
function sameEntry(a: FolderEntry, b: FolderEntry): boolean {
return a.isFile === b.isFile && a.size === b.size && a.mtimeMs === b.mtimeMs;
}
/** 읽어 둔 결과로 글 목록 · 문제를 만든다 — 캐시는 파일 이름 차례로 넣었다 */
function snapshotOf(
cache: ReadonlyMap<string, CachedEntry>,
): ReleaseNotesSnapshot {
const notes: ReleaseNote[] = [];
const problems: ReleaseNoteFileProblem[] = [];
for (const [file, { outcome }] of cache) {
if (outcome.kind === 'note') notes.push(outcome.note);
if (outcome.kind === 'problems') {
for (const { line, reason } of outcome.problems) {
problems.push({ file, line, reason });
}
}
}
notes.sort(compareReleaseNotes);
return { notes, problems };
}
/**
* 업데이트 내역 글 폴더 (계약 7절 "읽기") — 요청 때 폴더를 본다: 파일 이름 · 크기 · 바뀐 시각이 앞과 같으면 앞서 읽은 결과를 쓴다
* (폴더가 통째로 그대로면 앞의 결과 그대로 — 파일 수십 개라 이름 보기 · lstat 비용은 작다). 개발 서버는 다시 띄우지 않아도 새 글이 보이고,
* 운영은 이미지 안의 폴더라 배포 때 바뀐다. 함께 온 요청은 한 번 읽은 결과를 나눈다.
*
* 틀린 글 · 글이 아닌 파일(다른 이름 · 폴더 · 링크)은 건너뛰고 바뀔 때마다 error 로그 한 번(파일 이름 · 줄 번호 · 까닭 — 글 내용은 싣지 않는다).
* 기동은 막지 않는다 — 기동 때 한 번 읽어 틀린 글의 로그가 배포 직후에 보인다. 폴더가 없으면 빈 목록 + warn 한 번.
* 시험(CI)은 실제 폴더를 읽어 문제가 하나도 없어야 한다(release-notes.content.spec.ts).
* 모듈은 useFactory 로 만든다(생성자의 폴더 인자를 DI 가 찾지 않게) — 시험은 임시 폴더를 넘긴다
*/
export class ReleaseNotesFolder implements OnApplicationBootstrap {
private readonly logger = new Logger(ReleaseNotesFolder.name);
private cache = new Map<string, CachedEntry>();
/** 앞서 본 폴더의 모양(이름 · 크기 · 바뀐 시각) — 같으면 앞의 결과를 그대로 */
private signature: string | null = null;
private snapshot: ReleaseNotesSnapshot = EMPTY_SNAPSHOT;
private loading: Promise<ReleaseNotesSnapshot> | null = null;
private warnedMissing = false;
constructor(readonly dir: string = RELEASE_NOTES_DIR) {}
/**
* 기동 때 한 번 읽는다 — 틀린 글의 error 로그가 첫 요청이 아니라 배포 직후의 로그에 보이게.
* 읽지 못해도 던지지 않는다(기동을 막지 않는다 — 요청 때 다시 읽는다)
*/
async onApplicationBootstrap(): Promise<void> {
try {
await this.read();
} catch (error) {
this.logger.error(
`[SYS_001] 업데이트 내역 폴더를 기동 때 읽지 못했습니다 — 요청 때 다시 읽습니다: dir=${this.dir}, reason=${reasonOf(error)}`,
);
}
}
/** 지금 폴더의 글 · 문제 — 읽는 중이면 그 결과를 함께 기다린다 */
read(): Promise<ReleaseNotesSnapshot> {
if (this.loading !== null) return this.loading;
const loading = this.load().finally(() => {
this.loading = null;
});
this.loading = loading;
return loading;
}
private async load(): Promise<ReleaseNotesSnapshot> {
const entries = await this.listEntries();
if (entries === null) return this.folderMissing();
this.warnedMissing = false;
const signature = JSON.stringify(entries);
if (signature === this.signature) return this.snapshot;
const next = new Map<string, CachedEntry>();
for (const entry of entries) {
const cached = this.cache.get(entry.name);
next.set(
entry.name,
cached !== undefined && sameEntry(cached.entry, entry)
? cached
: { entry, outcome: await this.readEntry(entry) },
);
}
this.cache = next;
this.signature = signature;
this.snapshot = snapshotOf(next);
return this.snapshot;
}
/** 폴더의 이름들(이름 차례) · 크기 · 바뀐 시각 — 폴더가 없으면 null. 링크는 따라가지 않는다(lstat) */
private async listEntries(): Promise<FolderEntry[] | null> {
let names: string[];
try {
names = await readdir(this.dir);
} catch (error) {
const code = errorCodeOf(error);
if (code === 'ENOENT' || code === 'ENOTDIR') return null;
throw error;
}
names.sort();
const entries: FolderEntry[] = [];
for (const name of names) {
try {
const stats = await lstat(join(this.dir, name));
entries.push({
name,
isFile: stats.isFile(),
size: stats.size,
mtimeMs: stats.mtimeMs,
});
} catch (error) {
// 이름을 본 뒤 지워졌다 — 없는 것으로
if (errorCodeOf(error) === 'ENOENT') continue;
throw error;
}
}
return entries;
}
/** 바뀐(또는 처음 본) 이름 하나를 읽는다 — 문제가 있으면 여기서 한 번 error 로그 */
private async readEntry(entry: FolderEntry): Promise<EntryOutcome> {
const outcome = await this.readOutcome(entry);
if (outcome.kind === 'problems') {
this.logger.error(
`[SYS_001]${traceTag()} 업데이트 내역 글을 건너뜁니다 — 글 파일을 고쳐 주세요: file=${JSON.stringify(entry.name)}, problems=${outcome.problems.map(describeReleaseNoteProblem).join(' · ')}`,
);
}
return outcome;
}
private async readOutcome(entry: FolderEntry): Promise<EntryOutcome> {
const kind = classifyReleaseNoteFile(entry.name);
if (kind.kind === 'readme') return { kind: 'skipped' };
if (!entry.isFile) return problemsOf('not-a-file');
if (kind.kind === 'not-a-note') return problemsOf(kind.reason);
if (entry.size > RELEASE_NOTE_FILE_MAX_BYTES)
return problemsOf('too-large');
// 이름을 본 뒤 링크로 바뀌었거나 커졌을 수 있다 — 연 파일로 다시 본다
const read = await readReleaseNoteFile(join(this.dir, entry.name));
if (!read.ok) return problemsOf(read.reason);
let text: string;
try {
text = UTF8.decode(read.bytes);
} catch {
return problemsOf('encoding');
}
const parsed = parseReleaseNoteText(text);
if (!parsed.ok) return { kind: 'problems', problems: parsed.problems };
return {
kind: 'note',
note: {
id: kind.id,
date: kind.date,
sequence: kind.sequence,
title: parsed.title,
sections: parsed.sections,
},
};
}
/** 폴더가 없다 — 빈 목록, warn 은 없어질 때마다 한 번 */
private folderMissing(): ReleaseNotesSnapshot {
if (!this.warnedMissing) {
this.warnedMissing = true;
this.logger.warn(
`[SYS_001]${traceTag()} 업데이트 내역 폴더가 없어 빈 목록으로 답합니다: dir=${this.dir}`,
);
}
this.cache = new Map();
this.signature = null;
this.snapshot = EMPTY_SNAPSHOT;
return EMPTY_SNAPSHOT;
}
}
@@ -0,0 +1,54 @@
import { join } from 'path';
/**
* 업데이트 내역 (계약 7절 "업데이트 내역") — 한도 · 형식 · 자리를 한 곳에 모은다.
* 직원이 웹메일 · 휴대폰 앱에서 무엇이 새로 생겼는지 읽는 게시판이고, 글은 운영자가 저장소의 글 파일로 쓴다(올리는 API 는 없다).
* 글자 수는 앞뒤 빈칸을 지운 값의 JS 문자열 길이(UTF-16 코드 단위 — 웹의 `.length` 와 같다)로 센다.
*/
/** 라우트 경로 (`/api/release-notes`) */
export const RELEASE_NOTES_ROUTE = 'release-notes';
/**
* 글 폴더 — 저장소의 `backend/release-notes/`(src 밖). `__dirname` 기준이라 ts(src/modules/release-notes)와
* 빌드 산출물(dist/modules/release-notes) 양쪽에서 같은 곳이다 — 운영 이미지는 `/app/release-notes`(backend/Dockerfile 의 COPY),
* 개발 컨테이너는 backend 를 `/app` 에 바인드 마운트하므로 파일을 더하면 다시 띄우지 않아도 보인다
*/
export const RELEASE_NOTES_DIR = join(
__dirname,
'..',
'..',
'..',
'release-notes',
);
/** 쓰는 사람용 안내 — 글이 아니라 건너뛴다(로그도 남기지 않는다) */
export const RELEASE_NOTES_README = 'README.md';
/** 글 파일의 확장자 — 이름은 `YYYY-MM-DD.md` · 같은 날 둘째부터 `YYYY-MM-DD-2.md` … (이름 = 글 id) */
export const RELEASE_NOTE_FILE_EXTENSION = '.md';
/**
* 글 id — 글 파일 이름에서 확장자를 뗀 것. 같은 날 둘째부터 `-2` … `-99`(`-1` 은 없다 — 첫 글은 번호 없이).
* 경로의 id 도 이 모양(과 길이)만 본다 — 파일 경로로 쓰지 않고 읽어 둔 목록에서 찾는다
*/
export const RELEASE_NOTE_ID_PATTERN =
/^(\d{4})-(\d{2})-(\d{2})(?:-([2-9]|[1-9]\d))?$/;
/** 경로 id 의 길이 한도 — 모양을 보기 전에 본다 */
export const RELEASE_NOTE_ID_MAX_LENGTH = 32;
/** 번호 없는 글(그날 첫 글)의 차례 번호 */
export const RELEASE_NOTE_FIRST_SEQUENCE = 1;
/** 길이 한도 — 제목 100자 · 묶음 머리 40자 · 한 줄 500자 (이어 붙인 뒤) */
export const RELEASE_NOTE_TITLE_MAX_LENGTH = 100;
export const RELEASE_NOTE_HEADING_MAX_LENGTH = 40;
export const RELEASE_NOTE_ITEM_MAX_LENGTH = 500;
/** 글 파일 하나의 크기 한도 — 넘으면 읽지 않고 건너뛴다(글은 몇 KB — 잘못 넣은 큰 파일을 막는다) */
export const RELEASE_NOTE_FILE_MAX_BYTES = 256 * 1024;
/** 글 하나에서 알려 주는 문제 수 — 고치고 다시 보면 나머지가 나온다 (로그 한 줄이 끝없이 길어지지 않게) */
export const RELEASE_NOTE_PROBLEMS_MAX = 20;
/** 목록 커서의 범위 — 거르는 값이 없는 목록 하나 (shared/utils/offset-cursor.ts) */
export const RELEASE_NOTES_CURSOR_SCOPE = 'release-notes';
@@ -0,0 +1,43 @@
import { Logger } from '@nestjs/common';
import { existsSync } from 'fs';
import { join } from 'path';
import { describeReleaseNoteProblem } from './release-note-format';
import {
RELEASE_NOTES_DIR,
RELEASE_NOTES_README,
} from './release-notes.constants';
import { ReleaseNotesFolder } from './release-notes-folder';
/**
* 실제 글 폴더(backend/release-notes)를 엄격하게 읽는다 — 운영자가 더한 글의 오타를 CI 가 잡는다.
* 서버는 틀린 글을 건너뛰고 뜨지만(로그만), 여기서는 틀린 글 · 글이 아닌 파일이 하나라도 있으면 실패한다.
* 실패하면 "파일 이름 줄 번호 까닭(설명)"이 보인다 — 글을 고치는 법은 그 폴더의 README.md
*/
describe('업데이트 내역 글 폴더 (backend/release-notes — 엄격하게)', () => {
beforeEach(() => {
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
});
afterEach(() => {
jest.restoreAllMocks();
});
it('틀린 글 · 글이 아닌 파일이 하나도 없고 글이 하나 이상 있다', async () => {
const snapshot = await new ReleaseNotesFolder(RELEASE_NOTES_DIR).read();
expect(
snapshot.problems.map(
(problem) => `${problem.file} ${describeReleaseNoteProblem(problem)}`,
),
).toEqual([]);
expect(snapshot.notes.length).toBeGreaterThan(0);
});
it('쓰는 사람용 안내(README.md)가 있다', () => {
expect(existsSync(join(RELEASE_NOTES_DIR, RELEASE_NOTES_README))).toBe(
true,
);
});
});
@@ -0,0 +1,87 @@
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
import {
ApiCookieAuth,
ApiOperation,
ApiParam,
ApiTags,
} from '@nestjs/swagger';
import {
ApiErrorResponse,
ApiSessionErrors,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import { NoStore } from '@/common/decorators/no-store.decorator';
import {
CursorPaginationQueryDto,
type PaginatedResult,
} from '@/common/dto/pagination.dto';
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
import { ReleaseNoteIdPipe } from './release-note-id.pipe';
import {
RELEASE_NOTE_ID_MAX_LENGTH,
RELEASE_NOTES_ROUTE,
} from './release-notes.constants';
import { ReleaseNotesService } from './release-notes.service';
import type {
ReleaseNoteDetail,
ReleaseNoteSummary,
} from './release-notes.types';
import { ReleaseNoteDto, ReleaseNotePageDto } from './dto/release-note.dto';
/**
* 업데이트 내역 (계약 7절) — 직원이 웹메일 · 휴대폰 앱에서 무엇이 새로 생겼는지 읽는 게시판. 읽기만 있다 —
* 글은 운영자가 저장소의 `backend/release-notes/` 에 글 파일로 쓴다(올리는 API 는 없다).
* 로그인한 직원만(웹 쿠키 · 앱 Bearer — JwtAuthGuard). 제한된 세션(새 비밀번호 · 2단계 켜기 전)은 403 AUTH_010 — `@AllowRestrictedSession()` 을 달지 않는다.
* 요청 수는 기본 한도. 응답은 private, no-store — 글을 고친 배포가 바로 보이게
*/
@ApiTags('ReleaseNotes')
@ApiCookieAuth()
@UseGuards(JwtAuthGuard)
@ApiSessionErrors()
@Controller(RELEASE_NOTES_ROUTE)
export class ReleaseNotesController {
constructor(private readonly releaseNotes: ReleaseNotesService) {}
@Get()
@NoStore()
@ApiOperation({
summary: '업데이트 내역 목록 (커서 페이지네이션 · total 포함)',
description:
'최근 글부터 — 날짜 내림차순, 같은 날은 뒤 번호가 먼저(`2026-11-18-2` → `2026-11-18`). total 은 글 수. ' +
'커서는 위치라 차례 사이에 글을 더하면 다음 쪽에서 한두 개가 겹치거나 빠질 수 있다. 형식이 틀린 글 파일은 건너뛴다(목록에 없다). ' +
'Cache-Control: private, no-store.',
})
@ApiStandardResponse(ReleaseNotePageDto, {
description: '업데이트 내역 목록',
})
@ApiErrorResponse(
'VAL_001',
'잘못된 커서 · limit 범위 밖(1~100) · 모르는 쿼리',
)
list(
@Query() query: CursorPaginationQueryDto,
): Promise<PaginatedResult<ReleaseNoteSummary>> {
return this.releaseNotes.list(query);
}
@Get(':id')
@NoStore()
@ApiOperation({
summary: '업데이트 내역 글 하나 (묶음 · 이전 글 · 다음 글)',
description:
'previous 는 바로 더 옛 글(목록에서 이 글 아래), next 는 바로 더 새 글(목록에서 이 글 위) — 없으면 null. ' +
'글은 서버가 다듬은 글 그대로다 — 마크다운 · HTML 로 해석하지 않고 글로만 그린다. ' +
'없는 글 · 모양이 틀린 id 모두 404 RES_001(400 이 아니다 — 손으로 고친 주소도 "없는 글"). Cache-Control: private, no-store.',
})
@ApiParam({
name: 'id',
description: `글 id — 글 파일 이름(\`YYYY-MM-DD\` · 같은 날 두 번째 글부터 \`YYYY-MM-DD-2\` …, ${RELEASE_NOTE_ID_MAX_LENGTH}자까지)`,
example: '2026-11-18',
})
@ApiStandardResponse(ReleaseNoteDto, { description: '업데이트 내역 글' })
@ApiErrorResponse('RES_001', '없는 글 (모양이 틀린 id 포함)')
get(@Param('id', ReleaseNoteIdPipe) id: string): Promise<ReleaseNoteDetail> {
return this.releaseNotes.get(id);
}
}
@@ -0,0 +1,23 @@
import { Module } from '@nestjs/common';
import { ReleaseNotesController } from './release-notes.controller';
import { ReleaseNotesFolder } from './release-notes-folder';
import { ReleaseNotesService } from './release-notes.service';
/**
* 업데이트 내역 모듈 (계약 7절 — `GET /api/release-notes` · `/api/release-notes/{id}`) — 읽기만.
* 글은 저장소의 `backend/release-notes/`(글 파일 하나가 글 하나 — release-notes.constants.ts 의 RELEASE_NOTES_DIR).
* 폴더는 useFactory 로 만든다 — 시험은 ReleaseNotesFolder 를 임시 폴더로 바꿔 끼운다(env 를 따로 두지 않는다).
* 이 모듈은 다른 기능 모듈을 가져오지 않는다 (DB 표 없음)
*/
@Module({
controllers: [ReleaseNotesController],
providers: [
{
provide: ReleaseNotesFolder,
useFactory: () => new ReleaseNotesFolder(),
},
ReleaseNotesService,
],
})
export class ReleaseNotesModule {}
@@ -0,0 +1,186 @@
import { Logger } from '@nestjs/common';
import { mkdtempSync, rmSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { CursorPaginationQueryDto } from '@/common/dto/pagination.dto';
import { BusinessException } from '@/common/errors/business.exception';
import { encodeOffsetCursor } from '@/shared/utils/offset-cursor';
import { ReleaseNotesFolder } from './release-notes-folder';
import { ReleaseNotesService } from './release-notes.service';
/** 목록 쿼리 — 전역 파이프가 만든 것처럼(limit 기본 20) */
function queryOf(
values: Partial<Pick<CursorPaginationQueryDto, 'cursor' | 'limit'>> = {},
): CursorPaginationQueryDto {
return Object.assign(new CursorPaginationQueryDto(), values);
}
/** 던진 업무 오류의 코드 */
async function codeOf(work: Promise<unknown>): Promise<string> {
try {
await work;
} catch (error) {
if (error instanceof BusinessException) return error.code;
throw error;
}
throw new Error('던지지 않았다');
}
describe('ReleaseNotesService — 목록 · 글 (실제 폴더를 읽는다)', () => {
let dir: string;
let service: ReleaseNotesService;
const write = (name: string, text: string) =>
writeFileSync(join(dir, name), text);
/** 제목 · 묶음 하나의 글 */
const note = (title: string) =>
`# ${title}\n\n## 묶음\n- ${title}의 첫 줄\n- 둘째 줄\n`;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'arasystem-release-notes-'));
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
service = new ReleaseNotesService(new ReleaseNotesFolder(dir));
// 파일을 만든 차례와 상관없이 날짜 · 번호로 줄 세운다
write('2026-11-05.md', note('11월 5일 글'));
write('2026-11-18-2.md', note('18일 두 번째 글'));
write('2026-11-03.md', note('11월 3일 글'));
write('2026-11-18.md', note('18일 첫 글'));
write('2026-11-18-10.md', note('18일 열 번째 글'));
});
afterEach(() => {
jest.restoreAllMocks();
rmSync(dir, { recursive: true, force: true });
});
it('목록은 최근 글부터 — 날짜 내림차순, 같은 날은 뒤 번호가 먼저, 칸은 id · date · title 만, total 은 글 수', async () => {
const page = await service.list(queryOf());
expect(page).toEqual({
items: [
{ id: '2026-11-18-10', date: '2026-11-18', title: '18일 열 번째 글' },
{ id: '2026-11-18-2', date: '2026-11-18', title: '18일 두 번째 글' },
{ id: '2026-11-18', date: '2026-11-18', title: '18일 첫 글' },
{ id: '2026-11-05', date: '2026-11-05', title: '11월 5일 글' },
{ id: '2026-11-03', date: '2026-11-03', title: '11월 3일 글' },
],
pageInfo: { hasNext: false, nextCursor: null, total: 5 },
});
});
it('쪽 나누기 — nextCursor 로 끝까지 받고, 마지막 쪽은 hasNext false · nextCursor null', async () => {
const first = await service.list(queryOf({ limit: 2 }));
expect(first.items.map((item) => item.id)).toEqual([
'2026-11-18-10',
'2026-11-18-2',
]);
expect(first.pageInfo).toMatchObject({ hasNext: true, total: 5 });
expect(typeof first.pageInfo.nextCursor).toBe('string');
const second = await service.list(
queryOf({ limit: 2, cursor: first.pageInfo.nextCursor ?? undefined }),
);
expect(second.items.map((item) => item.id)).toEqual([
'2026-11-18',
'2026-11-05',
]);
const last = await service.list(
queryOf({ limit: 2, cursor: second.pageInfo.nextCursor ?? undefined }),
);
expect(last).toEqual({
items: [{ id: '2026-11-03', date: '2026-11-03', title: '11월 3일 글' }],
pageInfo: { hasNext: false, nextCursor: null, total: 5 },
});
});
it('틀린 커서 · 다른 목록의 커서는 폴더를 읽기 전에 400 VAL_001', async () => {
for (const cursor of [
'not-a-cursor',
'x'.repeat(300),
encodeOffsetCursor(2, 'contacts'),
]) {
expect(await codeOf(service.list(queryOf({ cursor })))).toBe('VAL_001');
}
});
it('글 — 묶음과 previous(바로 더 옛 글) · next(바로 더 새 글), 칸은 계약의 것만(차례 번호 없음)', async () => {
const detail = await service.get('2026-11-18');
expect(detail).toEqual({
id: '2026-11-18',
date: '2026-11-18',
title: '18일 첫 글',
sections: [{ heading: '묶음', items: ['18일 첫 글의 첫 줄', '둘째 줄'] }],
previous: { id: '2026-11-05', date: '2026-11-05', title: '11월 5일 글' },
next: {
id: '2026-11-18-2',
date: '2026-11-18',
title: '18일 두 번째 글',
},
});
expect(Object.keys(detail)).toEqual([
'id',
'date',
'title',
'sections',
'previous',
'next',
]);
});
it('맨 위 글은 next 가 null, 맨 아래 글은 previous 가 null', async () => {
expect(await service.get('2026-11-18-10')).toMatchObject({
previous: { id: '2026-11-18-2' },
next: null,
});
expect(await service.get('2026-11-03')).toMatchObject({
previous: null,
next: { id: '2026-11-05' },
});
});
it('없는 글은 404 RES_001', async () => {
for (const id of ['2026-11-19', '2026-11-18-3', '2026-02-30']) {
expect(await codeOf(service.get(id))).toBe('RES_001');
}
});
it('틀린 글은 목록 · 글에 없다 — 앞뒤 글은 맞는 글끼리 잇는다', async () => {
write('2026-11-10.md', '# 제목만 있는 글\n');
const page = await service.list(queryOf());
expect(page.items.map((item) => item.id)).not.toContain('2026-11-10');
expect(page.pageInfo.total).toBe(5);
expect(await codeOf(service.get('2026-11-10'))).toBe('RES_001');
expect(await service.get('2026-11-18')).toMatchObject({
previous: { id: '2026-11-05' },
});
});
it('글 파일을 더하면 다시 띄우지 않아도 다음 요청이 본다', async () => {
expect((await service.list(queryOf())).pageInfo.total).toBe(5);
write('2026-11-25.md', note('새 글'));
const page = await service.list(queryOf({ limit: 1 }));
expect(page.items).toEqual([
{ id: '2026-11-25', date: '2026-11-25', title: '새 글' },
]);
expect(page.pageInfo.total).toBe(6);
});
it('폴더가 없으면 빈 목록 — 글은 404 RES_001', async () => {
const empty = new ReleaseNotesService(
new ReleaseNotesFolder(join(dir, 'none')),
);
expect(await empty.list(queryOf())).toEqual({
items: [],
pageInfo: { hasNext: false, nextCursor: null, total: 0 },
});
expect(await codeOf(empty.get('2026-11-18'))).toBe('RES_001');
});
});
@@ -0,0 +1,87 @@
import { Injectable } from '@nestjs/common';
import {
type CursorPaginationQueryDto,
type PaginatedResult,
toPaginatedResult,
} from '@/common/dto/pagination.dto';
import { BusinessException } from '@/common/errors/business.exception';
import {
decodeOffsetCursor,
encodeOffsetCursor,
} from '@/shared/utils/offset-cursor';
import { RELEASE_NOTES_CURSOR_SCOPE } from './release-notes.constants';
import { ReleaseNotesFolder } from './release-notes-folder';
import type {
ReleaseNote,
ReleaseNoteDetail,
ReleaseNoteSummary,
} from './release-notes.types';
/** 응답에 싣는 칸만 — 읽어 둔 글의 차례 번호는 싣지 않는다 */
function summaryOf(note: ReleaseNote): ReleaseNoteSummary {
return { id: note.id, date: note.date, title: note.title };
}
/** 커서의 위치 — 없으면 0, 틀리면 400 VAL_001 (폴더를 읽기 전에 본다) */
function cursorOffset(cursor: string | undefined): number {
if (cursor === undefined) return 0;
const reading = decodeOffsetCursor(cursor, RELEASE_NOTES_CURSOR_SCOPE);
if ('problem' in reading) {
throw new BusinessException('VAL_001', {
context: { reason: `invalid-release-note-cursor:${reading.problem}` },
});
}
return reading.offset;
}
/**
* 업데이트 내역 (계약 7절) — 글 폴더(ReleaseNotesFolder)를 읽어 목록 · 글을 준다. 올리고 고치는 길은 없다(운영자가 글 파일을 더해 배포).
* 차례는 최근 글부터(날짜 내림차순, 같은 날은 뒤 번호가 먼저) — previous 는 바로 더 옛 글(목록의 아래), next 는 바로 더 새 글(목록의 위).
* 커서는 위치(offset-cursor) — 차례 사이에 글을 더하면 다음 쪽에서 한두 개가 겹치거나 빠질 수 있다(주소록과 같다)
*/
@Injectable()
export class ReleaseNotesService {
constructor(private readonly folder: ReleaseNotesFolder) {}
/** 목록 한 쪽 — total 은 글 수 */
async list(
query: CursorPaginationQueryDto,
): Promise<PaginatedResult<ReleaseNoteSummary>> {
const start = cursorOffset(query.cursor);
const { notes } = await this.folder.read();
const page = notes.slice(start, start + query.limit).map(summaryOf);
const next = start + page.length;
const hasNext = next < notes.length;
return toPaginatedResult(page, {
hasNext,
nextCursor: hasNext
? encodeOffsetCursor(next, RELEASE_NOTES_CURSOR_SCOPE)
: null,
total: notes.length,
});
}
/** 글 하나와 앞뒤 글 — 없으면 404 RES_001 (id 의 모양은 파이프가 보았다 — 여기서는 읽어 둔 목록에서만 찾는다) */
async get(id: string): Promise<ReleaseNoteDetail> {
const { notes } = await this.folder.read();
const index = notes.findIndex((note) => note.id === id);
if (index < 0) {
throw new BusinessException('RES_001', {
context: { reason: 'release-note-not-found', id },
});
}
const note = notes[index];
return {
id: note.id,
date: note.date,
title: note.title,
sections: note.sections.map((section) => ({
heading: section.heading,
items: [...section.items],
})),
previous: index + 1 < notes.length ? summaryOf(notes[index + 1]) : null,
next: index > 0 ? summaryOf(notes[index - 1]) : null,
};
}
}
@@ -0,0 +1,30 @@
/**
* 업데이트 내역의 모양 (계약 7절) — 응답과 읽어 둔 글이 함께 쓴다.
* `date` 는 달력 날짜 글(`YYYY-MM-DD` — 파일 이름에서)이고 시각이 아니다. 화면은 시간대를 바꾸지 않고 그대로 쓴다
*/
/** 목록의 한 줄 · 글 화면의 다른 글 줄 */
export interface ReleaseNoteSummary {
readonly id: string;
readonly date: string;
readonly title: string;
}
/** 묶음 — 머리(`## 머리`, 머리 없이 쓴 묶음은 null)와 줄(`- 한 줄`, 하나 이상) */
export interface ReleaseNoteSection {
readonly heading: string | null;
readonly items: readonly string[];
}
/** 글 하나 — 응답 (previous = 바로 더 옛 글, next = 바로 더 새 글 — 없으면 null) */
export interface ReleaseNoteDetail extends ReleaseNoteSummary {
readonly sections: readonly ReleaseNoteSection[];
readonly previous: ReleaseNoteSummary | null;
readonly next: ReleaseNoteSummary | null;
}
/** 읽어 둔 글 — 차례 번호(`sequence` — 같은 날 둘째부터 2 …, 번호 없는 글은 1)는 줄 세우기에만 쓰고 응답에 싣지 않는다 */
export interface ReleaseNote extends ReleaseNoteSummary {
readonly sequence: number;
readonly sections: readonly ReleaseNoteSection[];
}
+281 -1
View File
@@ -3,7 +3,14 @@ import { Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Test, TestingModule } from '@nestjs/testing';
import { createHash, createHmac, randomBytes, randomUUID } from 'crypto';
import { existsSync, mkdtempSync, rmSync, unlinkSync, writeFileSync } from 'fs';
import {
existsSync,
mkdirSync,
mkdtempSync,
rmSync,
unlinkSync,
writeFileSync,
} from 'fs';
import { get as httpGet, type IncomingMessage, type Server } from 'http';
import { connect } from 'net';
import { tmpdir } from 'os';
@@ -179,6 +186,7 @@ import {
type PushSendOutcome,
type PushSender,
} from '@/modules/push/push-sender.port';
import { ReleaseNotesFolder } from '@/modules/release-notes/release-notes-folder';
import {
SystemSettingsService,
type SystemSettingsPatch,
@@ -2114,6 +2122,9 @@ describe('응답 계약 (e2e)', () => {
// 휴대폰 앱 받기 — APK 파일은 이 시험의 임시 폴더에 (개발 업로드 볼륨에 남기지 않는다)
.overrideProvider(AppReleaseStorage)
.useValue(new AppReleaseStorage(join(fileDir, 'app-releases')))
// 업데이트 내역 — 글은 이 시험의 임시 폴더에서 읽는다 (실제 backend/release-notes 의 글이 바뀌어도 시험은 그대로)
.overrideProvider(ReleaseNotesFolder)
.useValue(new ReleaseNotesFolder(join(fileDir, 'release-notes')))
.compile();
// CI 의 빈 DB 에도 표가 있어야 한다 — 운영처럼 마이그레이션으로 만든다 (synchronize 를 켜지 않는다)
@@ -11354,6 +11365,275 @@ describe('응답 계약 (e2e)', () => {
});
});
/**
* 업데이트 내역 (계약 7절) — 글은 이 시험의 임시 폴더(위 overrideProvider 의 ReleaseNotesFolder)에 쓴다.
* 해석 규칙(형식 · 한도 · 이어 붙임 · 메모 · 날짜)은 release-note-format.spec.ts, 캐시 · 로그는 release-notes-folder.spec.ts,
* 실제 backend/release-notes 의 글은 release-notes.content.spec.ts 가 본다 — 여기서는 HTTP 형식 · 상태 코드를 본다
*/
describe('업데이트 내역 (/api/release-notes · /api/release-notes/{id})', () => {
const ME = 'release.notes@e2e.arasystem.test';
const LIST = '/api/release-notes';
let notesDir = '';
interface NoteSummary {
id: string;
date: string;
title: string;
}
interface NotePage {
items: NoteSummary[];
pageInfo: {
hasNext: boolean;
nextCursor: string | null;
total: number | null;
};
}
interface NoteDetail extends NoteSummary {
sections: { heading: string | null; items: string[] }[];
previous: NoteSummary | null;
next: NoteSummary | null;
}
const as = (test: HttpTest) => test.set('Cookie', cookieOf(ME));
const writeNote = (name: string, text: string) =>
writeFileSync(join(notesDir, name), text);
const TOP: NoteSummary = {
id: '2026-11-18-2',
date: '2026-11-18',
title: '같은 날 두 번째 글',
};
const RELEASE: NoteSummary = {
id: '2026-11-18',
date: '2026-11-18',
title: '메일 검색이 빨라지고 휴대폰 앱이 나왔습니다',
};
const COPY: NoteSummary = {
id: '2026-11-05',
date: '2026-11-05',
title: '화면의 안내 글을 다듬었습니다',
};
const OLDEST: NoteSummary = {
id: '2026-11-03',
date: '2026-11-03',
title: '2단계 인증과 앱 비밀번호를 쓸 수 있습니다',
};
beforeAll(() => {
notesDir = join(fileDir, 'release-notes');
mkdirSync(notesDir, { recursive: true });
writeNote('README.md', '# 쓰는 사람용 안내\n아무 글이나\n');
writeNote('2026-11-03.md', `# ${OLDEST.title}\n- 한 줄\n`);
writeNote(
'2026-11-18.md',
[
`# ${RELEASE.title}`,
'<!-- 메모는 화면에 나오지 않는다 -->',
'',
'## 휴대폰 앱',
'- 안드로이드 휴대폰 앱이 나왔습니다.',
'- 새 메일이 오면 휴대폰으로 알려 드립니다.',
' 알림에는 새 메일이 몇 통인지만 나옵니다.',
'',
'## 메일 검색',
'- 찾은 말에는 노란 표시가 붙습니다.',
'',
].join('\n'),
);
writeNote('2026-11-18-2.md', `# ${TOP.title}\n- 머리 없는 줄\n`);
writeNote('2026-11-05.md', `# ${COPY.title}\n- 한 줄\n`);
// 틀린 글 · 글이 아닌 파일 — 서버는 건너뛴다(error 로그만 — 기동 · 다른 글은 그대로)
writeNote('2026-11-10.md', '# 줄이 하나도 없는 글\n');
writeNote('notes.txt', '글 파일이 아니다');
});
it('로그인하지 않으면 401 AUTH_001 (목록 · 글)', async () => {
for (const path of [LIST, `${LIST}/2026-11-18`]) {
const res = await request(server).get(path).expect(401);
expect(body(res).error?.code).toBe('AUTH_001');
}
});
it('목록 200 { items: [{ id, date, title }], pageInfo } — 최근 글부터(같은 날은 뒤 번호가 먼저), total 은 글 수, 틀린 글 · 글이 아닌 파일은 없다 · private, no-store', async () => {
const res = await as(request(server).get(LIST)).expect(200);
expect(res.headers['cache-control']).toBe('private, no-store');
expect(body(res).success).toBe(true);
expect(dataOf<NotePage>(res)).toEqual({
items: [TOP, RELEASE, COPY, OLDEST],
pageInfo: { hasNext: false, nextCursor: null, total: 4 },
});
});
it('쪽 나누기 — limit 과 nextCursor 로 끝까지, 마지막 쪽은 hasNext false · nextCursor null', async () => {
const first = dataOf<NotePage>(
await as(request(server).get(LIST).query({ limit: 3 })).expect(200),
);
expect(first.items).toEqual([TOP, RELEASE, COPY]);
expect(first.pageInfo).toMatchObject({ hasNext: true, total: 4 });
expect(typeof first.pageInfo.nextCursor).toBe('string');
const second = await as(
request(server)
.get(LIST)
.query({ limit: 3, cursor: first.pageInfo.nextCursor ?? '' }),
).expect(200);
expect(dataOf<NotePage>(second)).toEqual({
items: [OLDEST],
pageInfo: { hasNext: false, nextCursor: null, total: 4 },
});
});
it('틀린 커서 · limit 범위 밖(1~100) · 모르는 쿼리는 400 VAL_001', async () => {
for (const query of [
{ cursor: 'not-a-cursor' },
{ limit: 0 },
{ limit: 101 },
{ limit: 'many' },
{ q: '검색' },
]) {
const res = await as(request(server).get(LIST).query(query)).expect(
400,
);
expect(body(res).error?.code).toBe('VAL_001');
}
});
it('글 200 — 묶음(메모 없음 · 이어 쓴 줄은 띄어쓰기 하나로) · previous(바로 더 옛 글) · next(바로 더 새 글) · private, no-store', async () => {
const res = await as(request(server).get(`${LIST}/2026-11-18`)).expect(
200,
);
expect(res.headers['cache-control']).toBe('private, no-store');
expect(dataOf<NoteDetail>(res)).toEqual({
...RELEASE,
sections: [
{
heading: '휴대폰 앱',
items: [
'안드로이드 휴대폰 앱이 나왔습니다.',
'새 메일이 오면 휴대폰으로 알려 드립니다. 알림에는 새 메일이 몇 통인지만 나옵니다.',
],
},
{
heading: '메일 검색',
items: ['찾은 말에는 노란 표시가 붙습니다.'],
},
],
previous: COPY,
next: TOP,
});
});
it('맨 위 글은 next null(머리 없는 묶음은 heading null), 맨 아래 글은 previous null', async () => {
const top = await as(request(server).get(`${LIST}/${TOP.id}`)).expect(
200,
);
expect(dataOf<NoteDetail>(top)).toEqual({
...TOP,
sections: [{ heading: null, items: ['머리 없는 줄'] }],
previous: RELEASE,
next: null,
});
const oldest = await as(
request(server).get(`${LIST}/${OLDEST.id}`),
).expect(200);
expect(dataOf<NoteDetail>(oldest)).toMatchObject({
previous: null,
next: COPY,
});
});
it('없는 글 · 건너뛴 틀린 글 · 모양이 틀린 id 는 모두 404 RES_001 — id 를 파일 경로로 쓰지 않는다', async () => {
for (const id of [
'2026-11-19',
'2026-11-10',
'2026-11-18-1',
'2026-11-18.md',
'README',
'latest',
'..%2F..%2Fpackage.json',
'x'.repeat(40),
]) {
const res = await as(request(server).get(`${LIST}/${id}`)).expect(404);
expect(body(res).error?.code).toBe('RES_001');
expect(body(res).error?.traceId).toBe(res.headers['x-request-id']);
}
});
it('글 파일을 더하면 서버를 다시 띄우지 않아도 다음 요청이 본다', async () => {
const added: NoteSummary = {
id: '2026-11-25',
date: '2026-11-25',
title: '새로 더한 글',
};
writeNote(`${added.id}.md`, `# ${added.title}\n- 한 줄\n`);
try {
const list = await as(
request(server).get(LIST).query({ limit: 1 }),
).expect(200);
expect(dataOf<NotePage>(list).items).toEqual([added]);
expect(dataOf<NotePage>(list).pageInfo).toMatchObject({
hasNext: true,
total: 5,
});
const top = await as(request(server).get(`${LIST}/${TOP.id}`)).expect(
200,
);
expect(dataOf<NoteDetail>(top).next).toEqual(added);
} finally {
unlinkSync(join(notesDir, `${added.id}.md`));
}
const after = await as(request(server).get(LIST)).expect(200);
expect(dataOf<NotePage>(after).pageInfo.total).toBe(4);
});
it('앱의 Bearer 토큰으로도 받는다 (웹 쿠키와 같은 가드)', async () => {
const token = sign(
{
sub: ME,
typ: 'access',
name: '테스트 직원',
cli: 'app',
sid: randomUUID(),
},
600,
);
const res = await request(server)
.get(`${LIST}/2026-11-18`)
.set('Authorization', `Bearer ${token}`)
.expect(200);
expect(dataOf<NoteDetail>(res).id).toBe('2026-11-18');
});
it('제한된 세션(새 비밀번호를 정하기 전)은 403 AUTH_010 — 허용 목록에 넣지 않는다', async () => {
const RESTRICTED = 'release.notes.expired@e2e.arasystem.test';
expiredPasswords.add(RESTRICTED);
try {
const login = await request(server)
.post('/api/auth/login')
.set('X-Forwarded-For', '198.18.41.1')
.send({ email: RESTRICTED, password: CORRECT_PASSWORD })
.expect(200);
expect(body(login).data).toMatchObject({
restriction: 'passwordChange',
});
const cookie = `access_token=${cookieValue(setCookie(login, 'access_token'))}`;
for (const path of [LIST, `${LIST}/2026-11-18`]) {
const res = await request(server)
.get(path)
.set('Cookie', cookie)
.expect(403);
expect(body(res).error?.code).toBe('AUTH_010');
}
} finally {
expiredPasswords.delete(RESTRICTED);
}
});
});
/**
* 15단계 — 메일함 옮기기 · 하위 메일함까지 지우기 · 차단할 때 받은편지함 정리 · 예약 메일 지금 보내기 (계약 2절).
* 엔진 쪽 판단(상위의 자리 · 고리 · 깊은 것부터 지우기 · Sieve 와 같은 판정 · 취소 → 다시 제출)은 jmap-*.spec.ts 가 본다 — 여기서는 HTTP 형식 · 검사 · 잠금 · 응답 꼴을 본다
+32 -1
View File
@@ -20,7 +20,8 @@
- 탭마다 이동 기록을 따로 지킨다(메일에서 읽던 화면이 설정 탭에 다녀와도 남는다). 읽기 · 쓰기는 탭 막대 없는 전체 화면이다.
- 여러 통 고르기 중에는 탭 막대를 숨긴다(잠깐의 상태이고, 다른 탭으로 가면 고른 것을 잃는다). ✕ · 뒤로 가기로 나오면 다시 보인다.
- 화면 낭독기 이름은 "앱 메뉴".
- **설정 탭**: 내 정보(이름 · 주소) · 알림(새 메일 알림 켬/끔) · 앱 정보(앱 버전 · [새 버전 받기] · 오픈소스 라이선스) · 문의 연락처 · [로그아웃]. 비밀번호 · 2단계 인증 같은 계정 설정은 웹에서 한다.
- **설정 탭**: 내 정보(이름 · 주소) · 알림(새 메일 알림 켬/끔) · 앱 정보(앱 버전 · 업데이트 내역 · [새 버전 받기] · 오픈소스 라이선스) · 문의 연락처 · [로그아웃]. 비밀번호 · 2단계 인증 같은 계정 설정은 웹에서 한다.
업데이트 내역(2026-11-10 — 아래 「업데이트 내역」)은 앱 버전 다음 줄이고 오픈소스 라이선스 줄과 같은 꼴이다.
[새 버전 받기](2026-11-08 — `App1Push` ④ 앱 정보 절)는 끝 아이콘이 바깥으로 여는 아이콘이고, 업데이트 화면의 단추처럼 포털의 휴대폰 앱 받기 화면을 브라우저로 연다(같은 실패 알림).
[로그아웃]은 데이터를 지우는 일이 아니라 회색 테두리 단추이고, 누르면 확인 창(처음 초점 [취소]) — 로그아웃하면 이 휴대폰의 새 메일 알림도 오지 않는다고 알린다.
@@ -303,5 +304,35 @@
- 2026-11-08 갤럭시 S23(Android 16)에서 본 것: 등록 소식(자동 초기화를 끈 채) · 같은 알림 바꿔 쓰기와 앱으로 돌아올 때 지우기 · 보는 중에는 그리지 않음 · 뒤에 있을 때와 꺼져 있을 때 누르면 받은편지함 · 작은 아이콘(잘 보인다) · 홈 화면 배지 · 바꿔 쓴 알림도 다시 소리를 냄 · 로그아웃 때 FCM 풀기(서버 줄은 서버가 지운다). 서버 쪽은 계약 1절 "실기기 · 실제 키로 확인할 것".
- 알려진 한계(2026-11-08): FID 로 등록하는 길은 Google Play 서비스 26.12(versionCode 261200000) 이상에서만 탄다 — 그보다 옛 휴대폰은 SDK(firebase-messaging 26.0.1)가 옛 토큰 길로 돌아가 등록 소식(`onRegistered`)의 값이 FID 가 아닐 수 있고, 서버는 FID 모양만 받으므로 켜기가 실패할 수 있다(아직 고치지 않았다 — 등록에 드는 매니페스트 표시는 flutter/README "새 메일 알림").
### 업데이트 내역 (`ReleaseNotes` B — 2026-11-10 확정, 캔버스 v80)
직원이 무엇이 새로 생기거나 바뀌었는지 읽는 게시판(목록 → 글)이다. 웹과 같은 글을 보여 준다(서버가 글 파일을 읽어 준다 — 웹 · 앱을 거르지 않는다).
보드는 웹 · 앱을 한 장에 그린 `docs/design/webmail/ReleaseNotes.dc.html` 의 B(앱)이고, 웹 화면은 웹 디자인 README 「업데이트 내역」, 글 파일 · API 는 계약 7절이다.
- **자리**: 설정 탭 › 앱 정보 절의 앱 버전 다음 줄 "업데이트 내역"(52 · 끝에 › — 오픈소스 라이선스 줄과 같은 꼴). 누르면 목록 `/settings/release-notes`, 줄을 누르면 글 `/settings/release-notes/{id}` —
둘 다 탭 막대 없는 전체 화면이고 글은 목록 위에 쌓인다(주소로 바로 열어도 아래에 목록이 있다).
- **목록**(B2): 위 막대 64(뒤로 · "업데이트 내역" 18 · 아래 선). 줄 68(설정의 두 줄 칸과 같다) — 제목 16 · 날짜 13(회색 · 숫자 고른 폭) · ›, 줄 전체를 누른다. 최근 글부터 20개이고
더 있으면 목록 아래 [더 보기](폭 전체 48 · 회색 테두리 — 20개를 넘을 때만). 받는 동안은 그 단추만 바쁨(둥근 진행 표시 18 · 회색 글 · 다시 누르지 못한다),
더 받지 못하면 그 코드의 사전 문구로 알리고 받은 줄은 그대로다.
- **글**(B3 · B4): 위 막대는 뒤로만이고 늘 목록으로 간다. 날짜 13 · 제목 18 · 묶음(머리 15 · 점 목록 15 / 줄 간격 1.6 · 회색 점 5) 차례이고 길면 화면째 스크롤한다.
끝에 다른 글 줄 64 — "다음 글 · 날짜"(더 새 글)가 위, "이전 글 · 날짜"(더 옛 글)가 아래(목록과 같은 차례), 없는 쪽은 줄이 없다. [목록] 단추는 두지 않는다.
- **상태**: 목록 — 불러오는 중은 줄 자리의 막대(B7 — 줄 68 · 제목 막대 13 · 날짜 막대 104 × 10, 끝이 둥글다), 받지 못함은 그 자리에 사전 문구 · 문의 코드 · [다시 시도](B8 — 알림 없음),
글이 없으면 문서 그림 원과 "아직 알려 드릴 업데이트가 없습니다." 한 줄(B9). 글 — 없는 글(지워졌거나 주소가 틀림 — 404 `RES_001`)은 사전 문구 · 문의 코드 · [목록으로](B5 — 알림 없음),
그 밖의 실패는 [다시 시도](B6). 받지 못함 · 없는 글의 제목은 긴 문장이라 15, 글이 없음의 제목은 빈 메일함과 같은 크기(18)다.
- **글 규칙**: 서버가 다듬은 글 그대로 그린다(마크다운 해석 · 링크 · 그림 없음 — 대괄호 글도 꾸미지 않는다). 날짜는 달력 날짜 그대로 "2026년 11월 18일"이다(시각으로 바꾸지 않는다 — 시간대와 상관없이 그날).
새 글 표시(점 · 개수 · NEW) · 번호 · 작성자 · 조회 수는 두지 않는다.
- **구현하며 정한 것**(2026-11-10)
- 받지 못함 · 가운데 상태의 틀과 불러오는 중 막대는 메일 목록에 있던 것을 `shared/widgets/load_states.dart`(`StateLayout` · `LoadErrorView` · `LoadingSkeleton` · `SkeletonBar`)로 옮겨 메일과 함께 쓴다 —
메일 화면의 모양 · 동작은 그대로다. 목록의 막대 줄은 B7 대로 따로 그렸다(메일 목록의 막대 줄에는 사진 원이 있다).
- 글을 받는 동안의 막대(보드에 없음): 날짜 · 제목 · 머리 · 줄 넷 자리를 목록 막대와 같은 결(끝이 둥근 막대)로 잡아 둔다. 화면 낭독기는 "글 불러오는 중"(웹과 같은 글).
- 다른 글 줄을 누르면 지금 글을 그 글로 바꿔 끼운다(쌓지 않는다 — 뒤로는 늘 목록). 새 화면이 들어오는 넘김이고 스크롤은 맨 위부터다.
- 글이 없을 때의 그림은 Material `description_outlined`(접힌 모서리와 글 줄 — 웹 `file-text` 와 같은 꼴, 보드의 "article_outlined 쯤").
- [더 보기]는 받는 동안에도 단추를 그대로 두고 누름만 받지 않는다(초점이 옮겨 가지 않게 — 메일 검색의 [더 찾기]와 같다). 응답의 모양이 틀린 [더 보기](서버 쪽 문제)도 `SYS_001` 로 알린다.
- 화면 낭독기: 목록 줄은 "제목, 날짜", 다른 글 줄은 "다음 글, 날짜, 제목"(가운뎃점 대신 쉼표로 쉰다)을 단추 하나로 읽는다. 받는 동안의 [더 보기]는 꺼진 단추 "더 보기, 불러오는 중"이다
(앱에서 새로 쓴 글 — 보드는 바쁨 표시만). 위 막대의 뒤로는 "뒤로"다.
- 세션이 바뀌면(로그아웃 · 다른 직원) 목록 · 글을 처음부터 받고 앞 세션의 늦은 응답은 버린다. 목록은 화면을 열 때마다 새로 받는다(닫으면 버린다).
- **실기기에서 볼 것**: 큰 글자에서 줄 높이 · 점 위치 · [더 보기], TalkBack 차례(위 막대 → 날짜 → 제목 → 머리 → 줄 → 다른 글 줄)와 [더 보기]를 받는 동안 읽기,
다른 글로 옮길 때의 넘김 · 뒤로 가기 몸짓, 업데이트 내역 서버(feat/release-notes)와 함께 실제 글(첫 글의 날짜는 배포 날로 고친다).
### 처음 판에서 미룬 것
서식 편집 · 대용량 첨부 · 필터 · 주소록 화면 · 서명 고치기 · 계정 설정(웹에서) · 오프라인 저장 · iOS. 검색은 결과에서 여러 통 고르기를 미뤘다(읽기 화면의 같은 대화 · 목록 대화로 묶기도 — 보드 먼저).
+112
View File
@@ -229,6 +229,24 @@
받지 않는 곳에 놓으면 브라우저가 그 파일을 열어 쓰던 메일을 잃기 때문이다 (2026-10-27 구현하며 정함).
- 이미지를 첨부로 돌렸을 때만 성공 알림 "이미지는 본문 대신 첨부 파일로 올렸습니다."를 띄운다(성공 알림과 같은 모양 · 5초).
### 받는 사람이 많을 때 · 낮은 화면 — 스크롤 (2026-11-10 사용자 보고 — 캔버스에는 없다)
전 직원 공지를 숨은참조로 쓰자 칸이 늘어 쓰기 칸이 넘쳤고, 바깥 스크롤 + 본문 스크롤의 이중 스크롤 속에 맨 아래 [보내기] 줄이 화면 밖으로 숨었다
(본문 위에서 휠을 돌리면 본문만 움직였다). 모양 · 크기는 그대로 두고 아래처럼 고쳤다.
- **[보내기] 줄은 늘 보인다** — 스크롤하는 칸(넓은 화면은 쓰기 칸, 좁은 화면은 문서)의 아래에 붙는다. 위 선 · 흰 바탕 · 여백은 그대로이고 넘칠 때만 내용 위에 뜬다.
[보내기 ▾] · [서명 ▾] 메뉴는 위로 연다. 겹침은 툴바 < [보내기] 줄 < 받는 사람 추천 목록 · 툴바의 메뉴(연 동안)다.
- **받는 사람 · 참조 · 숨은참조는 칩이 세 줄을 넘으면 그 칸 안에서 스크롤한다** — 보이는 높이 96: 맨 위에서는 칩 세 줄, 맨 아래(입력 자리)에서는
칩 두 줄 + 입력 줄로 줄 사이에서 끊겨 반쯤 잘린 칩이 보이지 않는다. 초점이 오거나 · 칩이 늘거나 · 글을 치면 맨 아래로 간다.
칩의 ✕ 로 지우면 초점은 입력칸으로 가되 칸의 자리는 그대로다(가운데 칩을 이어 지운다). 추천 목록은 칸 밖(줄 아래)에 뜬다.
- **넓은 화면(1120 이상)은 본문 편집 영역이 300 에서 200 까지 먼저 줄어든다** — 보통 높이(1366×768 · 1440×900)에서는 숨은참조 30명이어도
쓰기 칸 전체가 화면 안에 들고 본문만 스크롤한다(1366×768 에서 편집 영역 226). 그래도 넘치면(첨부가 많음 · 아주 낮은 창) 쓰기 칸이 스크롤하고
[보내기] 줄은 아래에 붙어 있다. 좁은 화면은 300 그대로이고 문서가 스크롤한다.
- [보내기] 줄에 가린 칸으로 키보드 · 코드의 초점이 오면 가린 만큼 올린다(WCAG 2.4.11 — 좁은 화면에서 Tab 으로 간 [파일 첨부]가 가렸다).
마우스 · 손가락으로 누른 칸은 그대로다 — 누른 채 내용이 움직이면 글이 골라진다.
- 넓은 화면에서 문서까지 168px 스크롤하던 것(화면 낭독 전용 글이 앱 틀 밖에 놓였다)도 고쳤다 — 쓰기 칸이 위치를 잡는다.
- 알려진 한계: 휴대폰 폭(360)에서는 [보내기] 줄이 세 줄(보내기 · 임시 저장 / 서명 · 저장 상태 / 보내는 사람 — 161px)로 화면의 1/5 을 덮는다 — 나중에 다듬는다.
## 2단계 — 임시 저장 · 서명 · 받는 사람 자동 완성 (2026-10-27 확정 — `Settings` · `ComposeAssist`)
### 임시 저장
@@ -302,6 +320,11 @@ API 와 서버 규칙은 계약 2절 "새 메일 확인" · "검색" · "여러
- **목록 보기 표**: 맨 앞에 고르기 칸(32 — 별 칸과 같은 밀도 예외). 상자 16px — 비었을 때 테두리 #8A94A3 1.5px, 고르면 #0E7C86 채움에 흰 체크.
머리의 상자는 불러온 메일 모두 고르기 · 풀기(일부만 골랐으면 – 표시). 고른 행 바탕 #E8F4F5(마우스를 올리면 #DDEFF0).
칸 = 고르기 32 · 별 32 · 보낸 사람 168 · 제목(나머지) · 날짜 84 · 크기 64 (칸 사이 12). 좁아질 때는 지금 규칙 그대로(1100 미만 크기 칸 숨김 · 600 미만 보낸 사람 112 · 날짜 72).
행 어디를 눌러도 메일을 열되 **고르기 칸 · 별 칸은 어디를 눌러도 열지 않는다** — 고르기 칸은 빈자리(단추 밖)를 눌러도 상자를 누른 것과 같이 고르고
(Shift 는 범위 · 고를 수 없는 줄이면 아무 일도 없다), 별 칸은 별 단추만 — 별 칸의 빈자리는 아무 일도 없다
(2026-11-10 사용자 보고 — 고르기 상자 가운데를 누르면 고르면서 메일까지 열렸다: 고르자 상자 그림이 다시 그려져 문서에서 떨어진 탓.
줄은 사건이 지나간 길로 가르고 상자 · 별 그림은 누름을 받지 않는다 — 대화로 묶은 표도 같다). Ctrl(⌘) · Shift + 클릭은 칸의 빈자리에서도 지금처럼 고른다.
분할 보기는 고르기 칸(36)이 줄의 링크 밖이라 이 탈이 없었다(같은 시험으로 확인).
- **분할 보기**: 행에 마우스를 올리거나 초점이 가면 왼쪽 36px 에 고르기 상자가 나타나고, 하나라도 고르면 모든 행에 보인다.
행을 누르면 지금처럼 메일을 연다. Ctrl(⌘)+클릭은 하나씩, Shift+클릭은 범위, Esc 는 선택 풀기 (두 보기 공통).
여러 통을 고르는 동안 본문 칸은 열어 둔 메일을 그대로 보인다.
@@ -1436,6 +1459,70 @@ PC 에서는 설정 › 계정 › 휴대폰 앱(A)이 QR 코드로 휴대폰에
주소록: 칸 늘리기(주소 · 생일 · 홈페이지 · 별명 · 사진), 겹친 연락처 합치기, 모르는 CSV 의 열을 손으로 맞추기, 옛 엑셀(.xls), 여러 연락처 고르기 · 한꺼번에 지우기, 사내 목록 내보내기.
서명: 받는 사람에 따라 저절로 바꾸기, 휴대폰 앱에서 서명 고르기. 부재중: 「내 연락처에게만」, 그림 넣기. 2단계 인증: 복구 코드, 바꿀 때 알림 메일 · 계정 변경 기록.
## 업데이트 내역 (2026-11-10 확정 — `ReleaseNotes`)
직원이 웹메일과 휴대폰 앱에 무엇이 새로 생기거나 바뀌었는지 읽는 게시판이다. 업데이트할 때마다 글을 하나씩 더하고, 직원은 목록에서 글을 골라 들어가 읽은 뒤 [목록]이나 뒤로 가기로 돌아온다.
글은 운영자가 저장소의 `backend/release-notes/` 에 글마다 파일 하나(`2026-11-18.md` — 이름이 날짜이자 글 주소)로 쓰고, 서버가 웹 · 앱에 같은 글을 준다. 올리는 화면 · API 는 없다(쓰는 법은 보드 C 와 그 폴더의 `README.md`).
서버 약속은 계약 7절("업데이트 내역")이다 — 목록 `GET /api/release-notes?cursor=&limit=` → `{ items: [{ id, date, title }], pageInfo }`(최근 글부터 · 20개씩),
글 `GET /api/release-notes/{id}` → `{ id, date, title, sections: [{ heading, items }], previous, next }`(없는 글 · 모양이 틀린 id 는 404 `RES_001`). 로그인해야 보이고 제한된 세션은 막힌다.
글의 원본은 화면 상수 `frontend/src/shared/constants/release-notes.ts` 이고 아래는 옮겨 적은 것이다. 글 내용(제목 · 묶음 · 줄)은 서버가 다듬은 글 그대로 그린다 — 마크다운 해석 · v-html 없음, 대괄호 글([설정] → [휴대폰 앱])도 꾸미지 않는다.
새 크기 토큰 · 밀도 예외 · 새 아이콘은 없다(메뉴 아이콘은 이미 있는 문서 그림 `file-text`). 새 글 표시(점 · 개수 · NEW)와 번호 · 작성자 · 조회 수 칸은 두지 않는다. 앱 화면(보드 B)은 앱 디자인 README 가 적는다.
### 자리 · 주소
- 설정 왼쪽 메뉴의 셋째 묶음 「정보」(계정 · 메일 다음) › 「업데이트 내역」. 주소는 목록 `/settings/info/release-notes`, 글 `/settings/info/release-notes/{id}`(id 는 글 파일 이름 — `2026-11-18`, 같은 날 둘째 글은 `2026-11-18-2`).
- 글을 보는 동안에도 메뉴의 「업데이트 내역」이 고른 상태(청록 바탕)다 — 메일함 패널의 지금 메일함과 같은 결. 화면 낭독은 목록이면 `aria-current="page"`,
글이면 링크가 가리키는 곳(목록)과 지금 쪽이 달라 `aria-current="true"` 다. 탭 제목은 목록 · 글 모두 "업데이트 내역 · ARA SYSTEM".
- `/settings/info` 는 목록으로 바꿔 끼운다 — 주소창에 글 주소 뒤 `..`(`%2E%2E` 도)를 치면 브라우저가 `/settings/info/` 로 다듬는다. 다른 묶음 주소(`/settings/account` · `/settings/mail`)는 그대로다.
- 날짜는 쓴 날짜 그대로 "2026년 11월 18일"이다. 시각이 아니라 달력 날짜라 브라우저 시간대로 바꾸지 않는다(`formatCalendarDate`).
### 목록 (`ReleaseNotes` A1)
- 머리 "업데이트 내역" + "웹메일과 휴대폰 앱에 새로 생기거나 바뀐 것을 알려 드립니다." 콘텐츠 열은 다른 설정 화면과 같은 800.
- 표 꼴 상자(로그인 기록 표와 같은 테두리 · 모서리 10): 머리 줄(32 · #FBFBFC · 12 회색 굵게) "제목" · "날짜"(136). 머리 줄은 보이는 꼴만이고, 화면 낭독은 줄 링크가 제목과 날짜를 함께 읽는다.
글마다 한 줄(48 이상 · 위아래 13): 제목(14 · 500) · 날짜(12.5 회색 · 숫자 고정폭), 최근 글부터. 제목이 길면 줄을 바꾸고 날짜는 오른쪽 칸에 둔다.
- 줄 전체가 그 글로 가는 링크다 — 마우스를 올리면 옅은 청록 바탕(#F3F8F8), 키보드 초점은 줄 안쪽 2px 청록 테두리.
- 처음 20개를 받고, 더 있으면 상자 아래(사이 16) [더 보기](44 · 흰 바탕 테두리)로 20개씩 더 받는다. 받는 동안 그 단추만 바쁨(`aria-busy` · `aria-disabled` · 흐리게)이고,
더 받지 못하면(응답의 모양이 틀려도 — `SYS_001`) 알림(사전 문구)으로 알리고 받은 줄 · [더 보기]는 그대로 둔다.
- 들어올 때마다 새로 받고, 받은 뒤 목록의 맨 위가 보이게 스크롤을 맞춘다(글에서 돌아와도 — 아래 보완 절 "스크롤 자리"). [더 보기] 뒤에는 맞추지 않는다.
### 글 (`ReleaseNotes` A2)
- 위에 [← 목록으로] 줄(52 · 아래 선 — 목록 보기에서 연 메일의 [목록으로]와 같은 꼴, 청록 굵은 글 · 화살표 15). 어떤 상태에서도 위에 남는다.
- 글을 열거나 다른 글로 옮기면 받은 뒤 글의 맨 위(날짜 · 제목)가 보이게 스크롤을 맞춘다(아래 보완 절 "스크롤 자리").
- 날짜(12.5 · 500 회색) · 제목(h1 18 · 줄 간격 1.4), 사이 4. 그 아래(사이 22) 묶음(묶음 사이 18): 머리(h2 14 굵게) + 점 목록(13 · #344054 · 줄 간격 1.6 · 줄 사이 4 · 회색 점 4).
머리가 없는 묶음은 점 목록만이다. 묶음 글은 680 까지.
- 글 끝(사이 22)의 다른 글 줄 — 위아래 선 · 680 까지. 줄(48 이상)은 이름(64 · 12.5 회색 굵게) · 제목(14 · 500) · 날짜(12.5 회색)이고 목록 줄처럼 줄 전체를 누른다.
「다음 글」(바로 더 새 글)이 위, 「이전 글」(바로 더 옛 글)이 아래(목록과 같은 차례)다. 없는 쪽의 줄은 두지 않는다(글이 하나뿐이면 칸째 없다).
- 맨 아래 [목록](44 · 흰 바탕 테두리 · 안쪽 18). 설명 한 줄은 목록 화면에만 둔다.
### 상태 (`ReleaseNotes` A1 · A2)
- 목록 · 불러오는 중: 머리 줄은 두고 줄 자리를 막대 넷(제목 막대 54 · 38 · 62 · 46%, 날짜 막대 96)으로 잡아 둔다 — 메일 목록을 불러올 때와 같은 막대. 화면 낭독 "업데이트 내역 불러오는 중".
- 목록 · 받지 못함: 상자 자리에 사전 문구 · [다시 시도] · 문의 코드(다른 설정 화면의 `MailLoadError`). 알림은 띄우지 않는다.
- 목록 · 글이 없을 때: 상자와 [더 보기] 없이 문서 그림 36 · "아직 알려 드릴 업데이트가 없습니다." 한 줄(`StateMessage`).
- 글 · 받는 동안: 글 자리를 막대로 잡아 둔다(날짜 · 제목 · 묶음 자리 — 보드에는 그리지 않았다). 화면 낭독 "글 불러오는 중".
- 글 · 없는 글(지워졌거나 주소가 틀림 — 404 `RES_001`): 알림 없이 사전 문구("찾을 수 없습니다. 이미 지워졌거나 옮겨졌을 수 있습니다.") · [목록으로] · 문의 코드.
파일 이름(날짜)을 고치면 글 주소가 바뀌어, 그전 주소로 들어오면 이 화면이다.
- 글 · 받지 못함(그 밖의 실패): 사전 문구 · [다시 시도] · 문의 코드. 알림은 띄우지 않는다.
### 좁은 폭 (`ReleaseNotes` A3)
- 화면 폭이 840보다 좁으면(레일 72 · 메뉴 208 · 글 칸 560 이 한 줄에 들지 않으면) 메뉴가 위로 올라가고 목록 · 글은 그 아래로 내려간다 — 지금 다른 설정 화면과 같다(휴대폰 브라우저에서는 메뉴 아래로 내려야 글이 나온다).
- **목록 칸이 480보다 좁으면**(화면 폭이 아니라 그 칸의 폭) 머리 줄을 감추고 날짜를 제목 아래로 내린다(사이 2 · 줄 안쪽 12 16) — 이것만 새로 정한 것이다.
글 화면은 넓은 화면과 같은 차례 · 글자 크기로 한 칸에 흐른다(좌우 여백 32 그대로 — 폭 360 에서 글 폭 224).
### 글 규칙 (보드 C 요약 — 쓰는 법의 원본은 `backend/release-notes/README.md`)
- 글 하나가 파일 하나다(`YYYY-MM-DD.md`, 같은 날 둘째부터 `YYYY-MM-DD-2.md`). 첫 줄 `# 제목`(꼭) · `## 묶음 머리` · `- 한 줄`, 빈칸 두 개로 시작한 줄은 앞 줄에 띄어쓰기 하나로 이어 붙고,
`<!-- 메모 -->` 는 화면에 나오지 않는다. 머리 없이 바로 `- ` 를 쓰면 머리 없는 묶음이다.
- 길이는 제목 100자 · 묶음 머리 40자 · 한 줄 500자. 굵게 · 링크 · 그림은 없다 — 단추 · 메뉴 이름은 화면 글처럼 `[설정] → [휴대폰 앱]`.
- 글도 위 「화면 문구 — 사용자에게 말하듯」을 따른다(직원이 읽는 글이다 — 정중하고 부드럽게, 만든 쪽 말 없이). 웹과 앱이 같은 글을 보여 주니 어디 이야기인지는 묶음 머리로 가른다.
### 처음 판에서 미룬 것
새 글 알림(안 읽은 표시 · 한 번 뜨는 안내 줄 — 뱃지를 쓰지 않는 원칙이라 배포 때 직원 공지 메일에 [설정] → [업데이트 내역]을 적는다), 글 안의 링크 · 그림 · 굵게, 웹 · 앱 거르기, 검색.
## 구현하며 정한 보완 (2026-10-26 — 캔버스에는 아직 없다)
- 찾지 못한 첨부(보관 시간이 지난 업로드·지워진 원본)는 실패로 표시하고, 전달 원본에는 "빼기"만 둔다(다시 올릴 수 없다).
@@ -2002,3 +2089,28 @@ PC 에서는 설정 › 계정 › 휴대폰 앱(A)이 QR 코드로 휴대폰에
꺼 둔 채로는 넘어도 회색이다(켤 때만 센다).
- 부재중 양식은 편집기와 함께 지연 로딩한다 — 청크를 받는 동안은 칸이 비어 있고, 못 받으면 서명 설정과 같은 사전 문구 + [다시 시도].
- 회사 밖 칸을 쓴 적 없으면 제목 · 내용을 null 로 보낸다(빈 글을 남기지 않는다 — 서버도 빈 글을 null 로 저장한다).
### 업데이트 내역 구현하며 정한 보완 (2026-11-10 — 캔버스에는 아직 없다)
데이터 · 경로는 위 "업데이트 내역" 머리말(계약 7절)과 같다.
- **기록**: 다른 글 줄은 기록을 바꿔 끼운다 — 몇 글을 넘겨 봐도 뒤로 가기는 목록으로 간다(메일 읽기 화면의 이전 · 다음과 같다).
[← 목록으로] · [목록] · 없는 글의 [목록으로]는 바로 앞 기록이 목록이면 뒤로 가기, 아니면 목록으로 바꿔 끼운다(메일의 [목록으로]와 같다). 링크라 새 탭으로도 열 수 있다.
- **[← 목록으로] 줄은 스크롤해도 위에 남는다**(읽기 화면의 도구 줄과 같다 — 긴 글을 읽다가도 바로 돌아간다).
그 줄 밑에 초점이 가리지 않게(WCAG 2.4.11) 글 칸의 제목 · 링크 · 단추는 줄 높이 + 8(61)만큼 띄워 스크롤한다(`scroll-margin-top`).
- **스크롤 자리**(2026-11-10 실사용 시험): 같은 문서 안의 이동이라 앞 화면의 스크롤 자리가 남아, 목록을 내린 채 글을 열면 날짜 · 제목이 위로 지나가 빈 글처럼 보였고
글에서 목록으로 돌아오면 목록의 맨 아래 근처가 보였다. 글을 열거나 다른 글로 옮기면 받은 뒤 글의 맨 위가, 목록은 받은 뒤 목록의 맨 위가 보이게 맞춘다
(`composables/useSettingsPageScroll.ts` — 라우터 전역 `scrollBehavior` 는 메일 등 다른 화면의 뒤로 가기까지 바꿔 쓰지 않는다). 스크롤하는 것이 폭마다 달라 둘 다 맞춘다:
넓은 화면(1120 이상)은 글 칸을 맨 위로, 좁은 화면은 문서를 — 맨 위로 올려도 제목이 화면 안이면 맨 위로(메뉴가 옆에 있는 폭), 아니면(위에 메뉴가 긴 휴대폰 · 낮은 창) 글 칸의 위 끝을 화면 위 끝에.
문서가 맨 위에 있으면(처음 연 화면) 그대로 둔다 — 다른 설정 화면처럼 휴대폰에서는 메뉴부터 보인다. 없는 글 · 받지 못함도 그 안내가 보이게 같은 규칙으로 맞춘다.
- **메뉴가 고른 상태**: 목록 · 글 라우트를 화면 없는 상위 라우트로 묶었다(`router/routes.ts`). 설정 메뉴는 그 항목을 고른 상태면 청록 바탕을 그리고,
`aria-current` 는 정확히 그 화면이면 `page` · 아래에 묶은 화면이면 `true` 다(RouterLink 의 `isExactActive` · `isActive`). 다른 메뉴 항목은 아래에 묶은 화면이 없어 그대로다.
- **초점**: 다른 글 줄을 누르면 그 줄이 사라져 초점을 잃는다 — 새 글을 받아 맨 위를 보이게 맞춘 뒤 제목(h1)으로 옮긴다(스크롤 없이 · 키보드로 왔을 때만 고리를 그린다).
[더 보기]로 마지막 쪽을 받아 단추가 사라졌으면 새로 붙은 첫 줄로 옮긴다(단추가 남으면 초점도 그대로).
- **주소의 글 id 가 글 id 꼴이 아니면**(`2026-11-18` · `2026-11-18-2` 꼴이 아님 · 32자 넘음) 묻지 않고 없는 글 화면이다(문의 코드 없음).
"." · ".." 같은 경로 조각은 브라우저가 주소를 다듬으며 다른 경로(목록 API 등)로 보내서 꼴부터 거른다.
- **목록을 받지 못했는데 404 여도**(그 경로가 없는 옛 서버) 빠져나갈 곳이 없어 [다시 시도]다.
- **응답의 모양**: 계약의 모양 그대로일 때만 읽는다 — 줄 · 묶음 · 다른 글 하나라도 틀리면(날짜가 실제 있는 달력 날짜가 아님 포함) 받지 못함(`SYS_001` 사전 문구)으로 본다(앱과 같은 규칙).
처음 목록 · 글은 칸 자리에 그리고(알림 없음), [더 보기]는 다른 실패와 같은 길로 알린다(받은 줄 · [더 보기]는 그대로).
- **다른 글 칸이 480보다 좁으면** 날짜를 제목 아래로 내린다(보드 A3 에는 그리지 않았다 — 목록 줄과 같은 기준). 이름 칸 64 는 그대로다.
- 목록 · 글은 들어올 때마다 새로 받는다(배포하며 글을 더하거나 고친다). 세션이 바뀌면(로그아웃 · 만료 · 다른 계정) 늦게 온 응답을 버리고 받아 둔 목록 · 글을 지운다.
File diff suppressed because it is too large Load Diff
+5 -3
View File
@@ -65,9 +65,10 @@
"Mailbox15.dc.html": { "x": 3040, "y": 42600, "w": 1440, "h": 4937, "title": "메일함 옮기기 · 하위까지 지우기 · 차단할 때 받은 메일", "is_interactive": true },
"Contacts16.dc.html": { "x": 0, "y": 51270, "w": 1440, "h": 7536, "title": "주소록 · 가져오기 · 내보내기", "is_interactive": true },
"Signature16.dc.html": { "x": 1520, "y": 51270, "w": 1440, "h": 7955, "title": "설정 · 여러 서명 · 쓰기 화면 서명 바꾸기", "is_interactive": true },
"Settings16.dc.html": { "x": 3040, "y": 51270, "w": 1440, "h": 8807, "title": "설정 · 부재중 서식 · 회사 밖 응답 · 휴대폰 바꾸기", "is_interactive": true }
"Settings16.dc.html": { "x": 3040, "y": 51270, "w": 1440, "h": 8807, "title": "설정 · 부재중 서식 · 회사 밖 응답 · 휴대폰 바꾸기", "is_interactive": true },
"ReleaseNotes.dc.html": { "x": 0, "y": 60700, "w": 1440, "h": 8464, "title": "설정 · 업데이트 내역 (웹 · 휴대폰 앱 · 쓰는 법)", "is_interactive": true }
},
"order": ["Main.dc.html", "Login.dc.html", "Compose.dc.html", "States.dc.html", "MainList.dc.html", "MainListRead.dc.html", "ComposeEditor.dc.html", "Settings.dc.html", "ComposeAssist.dc.html", "Search.dc.html", "BulkSelect.dc.html", "ListStates.dc.html", "SettingsAccount.dc.html", "SettingsVacation.dc.html", "MailboxManage.dc.html", "MailActions4.dc.html", "SettingsNotify.dc.html", "MailRealtime.dc.html", "SettingsFilter.dc.html", "MailFilterStates.dc.html", "MailFilterApply.dc.html", "MailFilterApplyStates.dc.html", "Contacts.dc.html", "ContactsEdit.dc.html", "ContactsMail.dc.html", "MailReadTools.dc.html", "MailPrint.dc.html", "MailSendLater.dc.html", "SettingsSendRead.dc.html", "ComposeLargeFiles.dc.html", "LargeFileBlock.dc.html", "MailReadLargeFiles.dc.html", "SharePage.dc.html", "SettingsLargeFiles.dc.html", "LoginTwoFactor.dc.html", "SettingsTwoFactor.dc.html", "SettingsAppPasswords.dc.html", "SettingsLoginHistory.dc.html", "LoginPasswordChange.dc.html", "LoginTwoFactorSetup.dc.html", "SettingsMailClients.dc.html", "MailNoticesCutover.dc.html", "Search14.dc.html", "Thread14.dc.html", "ListThreads14.dc.html", "App1Login.dc.html", "App1MailList.dc.html", "App1Reader.dc.html", "App1Compose.dc.html", "App1Shell.dc.html", "App1Search.dc.html", "App1SearchStates.dc.html", "App1SearchConditions.dc.html", "App1Push.dc.html", "SettingsMobileApp.dc.html", "Reader15.dc.html", "ListDrag15.dc.html", "Mailbox15.dc.html", "Contacts16.dc.html", "Signature16.dc.html", "Settings16.dc.html"],
"order": ["Main.dc.html", "Login.dc.html", "Compose.dc.html", "States.dc.html", "MainList.dc.html", "MainListRead.dc.html", "ComposeEditor.dc.html", "Settings.dc.html", "ComposeAssist.dc.html", "Search.dc.html", "BulkSelect.dc.html", "ListStates.dc.html", "SettingsAccount.dc.html", "SettingsVacation.dc.html", "MailboxManage.dc.html", "MailActions4.dc.html", "SettingsNotify.dc.html", "MailRealtime.dc.html", "SettingsFilter.dc.html", "MailFilterStates.dc.html", "MailFilterApply.dc.html", "MailFilterApplyStates.dc.html", "Contacts.dc.html", "ContactsEdit.dc.html", "ContactsMail.dc.html", "MailReadTools.dc.html", "MailPrint.dc.html", "MailSendLater.dc.html", "SettingsSendRead.dc.html", "ComposeLargeFiles.dc.html", "LargeFileBlock.dc.html", "MailReadLargeFiles.dc.html", "SharePage.dc.html", "SettingsLargeFiles.dc.html", "LoginTwoFactor.dc.html", "SettingsTwoFactor.dc.html", "SettingsAppPasswords.dc.html", "SettingsLoginHistory.dc.html", "LoginPasswordChange.dc.html", "LoginTwoFactorSetup.dc.html", "SettingsMailClients.dc.html", "MailNoticesCutover.dc.html", "Search14.dc.html", "Thread14.dc.html", "ListThreads14.dc.html", "App1Login.dc.html", "App1MailList.dc.html", "App1Reader.dc.html", "App1Compose.dc.html", "App1Shell.dc.html", "App1Search.dc.html", "App1SearchStates.dc.html", "App1SearchConditions.dc.html", "App1Push.dc.html", "SettingsMobileApp.dc.html", "Reader15.dc.html", "ListDrag15.dc.html", "Mailbox15.dc.html", "Contacts16.dc.html", "Signature16.dc.html", "Settings16.dc.html", "ReleaseNotes.dc.html"],
"notes": {
"flow": { "x": 0, "y": -300, "text": "arasystem 웹메일 — 1단계 화면 (로그인 → 메일함 → 쓰기)", "kind": "title1", "maxW": 4480 },
"phase2": { "x": 0, "y": 2180, "text": "2단계 화면 — 임시 저장 · 받는 사람 자동 완성 · 서명 (2026-10-27 확정)", "kind": "title1", "maxW": 2960 },
@@ -88,7 +89,8 @@
"app1push": { "x": 0, "y": 34600, "text": "앱 새 메일 알림 — 허락 묻기 · 설정 · 휴대폰 알림 · 업데이트 · 앱 아이콘 — 2026-11-07 확정(글은 화면 상수가 기준) · 11-08 업데이트 화면 · 앱 정보를 사내 배포에 맞게 바꿈(확정)", "kind": "title1", "maxW": 2222 },
"appdist": { "x": 0, "y": 38470, "text": "휴대폰 앱 받기 — 설정 › 휴대폰 앱 · 휴대폰 받기 화면(로그인한 직원만) — 2026-11-08 확정(글은 화면 상수가 기준)", "kind": "title1", "maxW": 1440 },
"phase15": { "x": 0, "y": 42300, "text": "15단계 화면 — 메일 · 메일함 다루기(이동 · 끌어다 놓기 · 메일함 옮기기 · 지금 보내기 · 같은 대화) — 2026-11-09 확정(글은 화면 상수가 기준)", "kind": "title1", "maxW": 4480 },
"phase16": { "x": 0, "y": 50970, "text": "16단계 화면 — 개인 설정(주소록 가져오기 · 내보내기 · 여러 서명 · 부재중 서식 · 회사 밖 응답 · 휴대폰 바꾸기) — 2026-11-10 확정(글은 화면 상수가 기준)", "kind": "title1", "maxW": 4480 }
"phase16": { "x": 0, "y": 50970, "text": "16단계 화면 — 개인 설정(주소록 가져오기 · 내보내기 · 여러 서명 · 부재중 서식 · 회사 밖 응답 · 휴대폰 바꾸기) — 2026-11-10 확정(글은 화면 상수가 기준)", "kind": "title1", "maxW": 4480 },
"releasenotes": { "x": 0, "y": 60400, "text": "업데이트 내역(게시판 — 목록에서 글로) — 웹 설정 › 정보 · 앱 설정 › 앱 정보 · 글 파일 쓰는 법 — 2026-11-10 확정(글은 화면 상수가 기준)", "kind": "title1", "maxW": 1440 }
},
"designSystems": []
}
+81
View File
@@ -8,6 +8,7 @@
- 성공 `{ "success": true, "data": … }` / 실패 `{ "success": false, "error": { "code", "message", "traceId" } }`
- 날짜·시각은 ISO 8601 UTC 문자열, 목록은 `{ items, pageInfo }`
(예외: 7절 업데이트 내역의 `date` 는 시각이 아닌 달력 날짜 글 `YYYY-MM-DD` 다 — `new Date()` 로 읽지 않고 시간대를 바꾸지 않은 채 그대로 쓴다)
- 이 단계에서 쓰는 에러 코드: `AUTH_001` `AUTH_002`(클라이언트 전용) `AUTH_003` `AUTH_004` `AUTH_005` `VAL_001` `RES_001` `REQ_001` `FILE_001` `FILE_002` `MAIL_001` `MAIL_002` `MAIL_003` `MAIL_004` `MAIL_005` `MAIL_006` `MAIL_007` `MAIL_008` `MAIL_009` `MAIL_010` `MAIL_011` `MAIL_012` `MAIL_013` `MAIL_014` `MAIL_015` `CONTACT_001` `CONTACT_002` `SYS_001`
(11단계 — 4절 "대용량 첨부"가 `FILE_003` ~ `FILE_008` · `MAIL_016` 을 더한다. 12단계 — 5절 "계정 보안"이 `AUTH_006` ~ `AUTH_009` 를, 13단계 — 6절 "전환 준비"가 `AUTH_010` 을 더한다)
- **적용 범위: 웹(브라우저)은 HttpOnly 쿠키뿐이고 토큰을 본문으로 주지 않는다.**
@@ -3288,3 +3289,83 @@ interface FileUploadCreate {
웹메일 관리자 화면(공통 기반 때), 비밀번호 스스로 재설정(복구 연락처 필요), 직원이 켜진 채 새 휴대폰으로 바꾸기(관리자의 「2단계 해제」로 한다),
공용 주소로 보내기 · 공유 메일함(Group), 새 기기 로그인 알림 메일, 관리자 열람 기능, 주소 바꾸기, 모바일 앱.
---
## 7. 업데이트 내역 (2026-11-10 정함 · 서버)
> 직원이 웹메일과 휴대폰 앱에서 무엇이 새로 생기거나 바뀌었는지 읽는 게시판이다. 업데이트할 때마다 글을 하나씩 더한다(목록에서 글을 골라 들어가 읽는다).
> 글은 운영자가 저장소의 `backend/release-notes/` 에 **글마다 파일 하나**로 쓴다 — 올리고 고치는 API 는 없다(파일을 더해 커밋 → 배포). 웹과 앱이 같은 글을 보고 거르지 않는다(어디 이야기인지는 묶음 머리가 말한다).
> 보드 `docs/design/webmail/ReleaseNotes.dc.html`(웹 A · 앱 B · 쓰는 법 C), 서버는 `modules/release-notes`. 새 에러 코드 · DB 표 · env 는 없다.
### 타입
```ts
interface ReleaseNoteSummary {
id: string // 글 파일 이름에서 .md 를 뗀 것 — "2026-11-18", 같은 날 두 번째 글부터 "2026-11-18-2" …
date: string // 달력 날짜 "YYYY-MM-DD"(파일 이름에서) — 시각이 아니다
title: string // 첫 줄의 # 제목 — 100자까지
}
interface ReleaseNoteSection {
heading: string | null // ## 머리 — 40자까지. 머리 없이 쓴 묶음은 null(글의 맨 앞에만 온다)
items: string[] // - 한 줄 — 500자까지, 하나 이상
}
interface ReleaseNote extends ReleaseNoteSummary {
sections: ReleaseNoteSection[] // 파일에 쓴 차례 — 하나 이상
previous: ReleaseNoteSummary | null // 바로 더 옛 글(목록에서 이 글 아래) — 없으면 null
next: ReleaseNoteSummary | null // 바로 더 새 글(목록에서 이 글 위) — 없으면 null
}
```
### 엔드포인트
| 메서드 · 경로 | 요청 | 성공 | 주요 실패 |
| --- | --- | --- | --- |
| `GET /api/release-notes` | `cursor` · `limit`(기본 20, 최대 100) | 200 `{ items: ReleaseNoteSummary[], pageInfo }` — 최근 글부터, `total` 은 글 수 | 400 `VAL_001`(틀린 커서 · limit 범위 밖 · 모르는 쿼리) |
| `GET /api/release-notes/{id}` | — | 200 `ReleaseNote` | 404 `RES_001` — 없는 글 · 모양이 틀린 id |
- 두 경로 모두 로그인 필요(웹 쿠키 · 앱 Bearer — `JwtAuthGuard`), 없으면 401 `AUTH_001`. 제한된 세션(6절)은 403 `AUTH_010`(허용 목록에 넣지 않는다 — 그 단계를 마치면 본다).
요청 수는 기본 한도(경로마다 IP 당 1분 100회 — 넘으면 429 `REQ_001`). 응답은 `Cache-Control: private, no-store`(글을 고친 배포가 바로 보이게).
- **차례**: 날짜 내림차순, 같은 날은 뒤 번호가 먼저(`2026-11-18-2` → `2026-11-18`). `previous` · `next` 는 이 차례의 아래 · 위다(목록 화면과 같은 차례).
- `cursor` 는 불투명 문자열(위치)이다. 차례 사이에 글을 더하면 다음 쪽에서 한두 개가 겹치거나 빠질 수 있다(3절과 같다).
- **id**: 서버는 모양(`YYYY-MM-DD` 또는 `YYYY-MM-DD-N` — N 은 2~99, 32자까지)만 보고 읽어 둔 목록에서 찾는다 — 파일 경로로 쓰지 않는다.
모양이 틀린 id 도 400 이 아니라 404 `RES_001` 이다(손으로 고친 주소도 "없는 글"). 파일 이름(날짜)을 고치면 글 주소도 바뀌어 옛 주소는 404 다.
- **글은 서버가 다듬은 그대로다** — 앞뒤 빈칸을 지우고, 안의 빈칸 · 탭 여럿은 하나로, 이어 쓴 줄은 띄어쓰기 하나로 붙이고, NFC 로 모은다.
마크다운(굵게 · 링크 꼴)은 해석하지 않고 쓴 글 그대로다 — 클라이언트도 글로만 그린다(v-html · 마크다운 해석 없음). 단추 · 메뉴 이름은 글에 `[설정] → [휴대폰 앱]` 처럼 쓴다.
- **날짜(`date`)는 달력 날짜 글이다** — 화면은 시간대를 바꾸지 않고 "2026년 11월 18일"로 쓴다. `new Date('2026-11-18')` 로 읽지 않는다(UTC 자정이라 음수 시간대에서 하루 앞이 된다).
### 글 파일 (`backend/release-notes/`)
- 글 하나 = 파일 하나. 이름 `YYYY-MM-DD.md`(글의 날짜 — 있는 날짜여야 한다: `2026-02-30` 은 틀림), 같은 날 둘째부터 `YYYY-MM-DD-2.md` … `-99`(`-1` 은 없다 — 첫 글은 번호 없이). 이름이 글 id 다.
- 폴더에는 `README.md`(쓰는 사람용 안내 — 서버는 읽지 않는다)와 글 파일만 둔다. 그 밖의 것(다른 이름 · 폴더 · 링크)은 글이 아니다.
- 파일 안 — 마크다운의 아주 작은 일부(글만). UTF-8, 256KB 까지:
| 줄 | 뜻 |
| --- | --- |
| `# 제목` | 첫 글 줄(앞의 빈 줄 · 메모는 된다) — 꼭 있어야 하고 한 번만. 100자까지 |
| `## 머리` | 묶음 시작 — 40자까지. 아래에 줄이 하나는 있어야 한다 |
| `- 한 줄` | 그 묶음의 한 줄 — 500자까지(이어 붙인 뒤). 머리 없이 바로 쓰면 머리 없는 묶음(`heading: null` — 제목 바로 아래에만) |
| 빈칸 두 개 이상(또는 탭)으로 시작한 줄 | 앞 `- ` 줄에 띄어쓰기 하나로 이어 붙는다(빈 줄 · 메모를 사이에 두어도). 들여 쓴 `- `(하위 목록)는 틀림 |
| `<!-- … -->` | 메모 — 화면에 나오지 않는다. 줄 앞에서 시작한다(앞 빈칸은 괜찮다 · 글 뒤에 붙이지 않는다). 여러 줄도 된다. 닫은 뒤 같은 줄에 글을 쓰지 않는다 |
| 빈 줄 | 어디든 |
그 밖의 줄(맨 글 · `###` · `* ` · `1. ` · 표 · 인용 · 한 칸 들여 쓴 줄 등), 글 안의 `<!--`, 보이지 않는 글자(제어 문자 · 줄 구분 문자)는 틀림. 글 하나에 줄이 하나는 있어야 한다.
글자 수는 앞뒤 빈칸을 지운 뒤의 JS 문자열 길이(UTF-16 — 웹의 `.length` 와 같다). 줄 끝의 CR(CRLF) · 앞의 BOM 은 지운다.
- **읽기**: 서버는 기동 때 한 번, 그 뒤로는 요청 때 폴더를 본다 — 파일 이름 · 크기 · 바뀐 시각이 앞과 같으면 앞서 읽은 결과를 쓴다(개발 서버는 다시 띄우지 않아도 새 글이 보인다).
운영은 이미지 안의 폴더(`/app/release-notes` — `backend/Dockerfile` 이 넣는다)라 배포해야 바뀐다. 함께 온 요청은 한 번 읽은 결과를 나눈다.
글 파일은 한 번 열어(링크를 따라가지 않는다) 그 열린 파일로 일반 파일인지 · 크기를 보고 256KB + 1 바이트까지만 읽는다.
- **틀린 글**: 시험(CI — `release-notes.content.spec.ts` 가 실제 폴더를 엄격하게 읽는다)이 실패한다. 그래도 배포되면 서버는 그 파일만 건너뛰고
바뀔 때마다 error 로그를 한 번 남긴다(파일 이름 · 줄 번호 · 까닭 — 줄 차례로 앞의 20개, 글 내용은 싣지 않는다). 기동 때 읽으므로 배포 직후의 로그에 보인다.
기동은 막지 않는다(기동 때 읽지 못해도 — 요청 때 다시 읽는다). 폴더가 없으면 빈 목록 + warn 한 번.
### 클라이언트
- **웹**: 설정 메뉴의 셋째 묶음 "정보" › "업데이트 내역" — 목록 `/settings/info/release-notes`, 글 `/settings/info/release-notes/{id}`(글을 보는 동안에도 메뉴 항목이 고른 상태). 404 `RES_001` 은 알림 없이 "없는 글" 화면.
- **앱**: 설정 탭 › 앱 정보 › "업데이트 내역" — 목록 `/settings/release-notes`, 글 `/settings/release-notes/{id}`(둘 다 탭 막대 없는 전체 화면). 앱은 옛 판이어도 서버의 새 글을 본다(새 APK 를 내지 않아도 된다).
- 화면 · 상태(불러오는 중 · 받지 못함 · 글 없음 · 없는 글)는 디자인 README(웹 · 앱)의 "업데이트 내역" 절.
### 이 단계에서 하지 않는 것
올리고 고치는 API · 화면, 새 글 표시(점 · 개수 · NEW — 뱃지를 쓰지 않는다), 웹 · 앱 거르기, 글 안의 링크 · 그림 · 굵게, 검색, 번호 · 작성자 · 조회 수.
+5 -5
View File
@@ -8,9 +8,9 @@
## 지금
- **운영**: 메일 1~13단계. 2026-11-05 옛 메일 서버(mailcow)에서 전 직원이 옮겨 왔다(메일만 옮겼다 — 주소록 · 달력 · 필터 · 부재중 · 서명은 직원이 새로).
- **배포를 기다림**: 메일 14 · 15단계 · 앱 서버(앱 로그인 · 새 메일 알림) · 휴대폰 앱 받기 — main 에 합쳤고, 전환 뒤 안정화 1~2주를 지나 11월 셋째 주에 운영에 올린다.
- **개발 중**: 메일 16단계(개인 설정).
- **다음**: 16단계가 끝날 즈음 아래 "추후 후보" 가운데 직원 요청이 실제로 있는 것을 고른다. 그다음은 공통 기반(조직 · 권한).
- **배포를 기다림**: 메일 14 · 15 · 16단계 · 앱 서버(앱 로그인 · 새 메일 알림) · 휴대폰 앱 받기 — main 에 합쳤고, 전환 뒤 안정화 1~2주를 지나 11월 셋째 주에 운영에 올린다.
- **개발 중**: 업데이트 내역 — 웹메일 · 휴대폰 앱의 설정에서 무엇이 새로 생기거나 바뀌었는지 읽는 게시판(글은 저장소의 글 파일, 셋째 주 배포에 함께).
- **다음**: 아래 "추후 후보" 가운데 직원 요청이 실제로 있는 것을 고른다. 그다음은 공통 기반(조직 · 권한).
## 1부 — 메일
@@ -31,9 +31,9 @@
| 13 | 전환 준비 — 새 비밀번호 · 2단계 필수 · 메일 프로그램 연결 안내 · 운영 도구 · 백업 · 감시 · 휴지통 · 스팸 30일 | 운영 |
| 14 | 자체 검색 색인 · 조건 검색 · 검색어 강조 · 같은 대화 · 대화로 묶어 보기 | main 에 합침 — 배포 기다림 |
| 15 | 메일 · 메일함 다루기 — 읽기 화면 [이동] · 끌어다 놓기 · 메일함 옮기기 · 하위까지 지우기 · 차단할 때 받은편지함 정리 · 예약 메일 [지금 보내기] · 같은 대화 [모두 펼치기] | main 에 합침 — 배포 기다림 |
| 16 | 개인 설정 — **주소록 가져오기 · 내보내기**(vCard · CSV · 엑셀) · 여러 서명(사내 · 대외 · 영문) · 부재중 응답의 서식 본문과 사내/외부 구분 · 2단계 인증 휴대폰 직접 바꾸기 | 개발 중 |
| 16 | 개인 설정 — **주소록 가져오기 · 내보내기**(vCard · CSV · 엑셀) · 여러 서명(사내 · 대외 · 영문) · 부재중 응답의 서식 본문과 사내/외부 구분 · 2단계 인증 휴대폰 직접 바꾸기 | main 에 합침 — 배포 기다림 |
- 15단계부터는 직원이 쓰며 바라는 것을 먼저 본다 — 16단계가 끝날 즈음 아래 "추후 후보" 가운데 요청이 실제로 있는 것을 골라 다음 단계로 넣는다.
- 15단계부터는 직원이 쓰며 바라는 것을 먼저 본다 — 아래 "추후 후보" 가운데 요청이 실제로 있는 것을 골라 다음 단계로 넣는다.
- 2026-11-09 계획에서 뺀 것: 수신 확인 · 사내 발송 취소(사내 받는 사람에게만 되고, 흔한 실수는 보내기 취소가 막는다 — 아래 후보로).
## 공통 기반 — 조직 · 권한
+14
View File
@@ -229,4 +229,18 @@ abstract final class AppSize {
/// 둥근 진행 표시(켜는 중 · 끄는 중)의 지름과 고리 두께
static const double progressRing = 22;
static const double progressRingStroke = 2.6;
// ── 앱 보드에서 정한 크기 (업데이트 내역 `ReleaseNotes` B — 2026-11-10 확정) ──
// 목록 줄은 설정의 두 줄 칸([settingsRowMinHeight] 68)과 같다
/// 글 화면 끝의 다른 글 줄(다음 글 · 이전 글)의 최소 높이
static const double noteLinkRowMinHeight = 64;
/// 글의 점 목록 — 점의 지름(점 칸 폭은 `AppSpacing.s16`)
static const double bulletDot = 5;
/// 목록을 불러오는 동안 줄의 제목 막대 높이(날짜 막대는 `AppSpacing.s10`)
static const double skeletonTitleBarHeight = 13;
/// 목록을 불러오는 동안 줄의 날짜 막대 폭
static const double skeletonDateBarWidth = 104;
}
+53
View File
@@ -0,0 +1,53 @@
import 'package:flutter/foundation.dart';
/// 달력 날짜 — 시각이 아닌 "그날"(서버의 `YYYY-MM-DD` 글, 예: 업데이트 내역 글의 쓴 날 — 계약 7절).
///
/// 시간대를 바꾸지 않는다 — 글을 직접 나눠 연 · 월 · 일 숫자로만 쥔다. `DateTime.parse('2026-11-18')` 은 기기 시간대의 자정이고
/// 웹의 `new Date('2026-11-18')` 은 UTC 자정이라(음수 시간대에서 하루 앞) 어느 쪽이든 "시각"으로 바꾸면 그날이 흔들릴 수 있다.
@immutable
class CalendarDate {
const CalendarDate(this.year, this.month, this.day);
final int year;
final int month;
final int day;
/// `YYYY-MM-DD` 글 → 날짜. 모양이 다르거나 없는 날(2026-02-30 · 2026-13-01)이면 null
static CalendarDate? tryParse(Object? value) {
if (value is! String) return null;
final RegExpMatch? match = _pattern.firstMatch(value);
if (match == null) return null;
final int year = int.parse(match.group(1)!);
final int month = int.parse(match.group(2)!);
final int day = int.parse(match.group(3)!);
if (month < 1 || month > _monthsInYear) return null;
if (day < 1 || day > _daysInMonth(year, month)) return null;
return CalendarDate(year, month, day);
}
/// 숫자 칸 셋 — 연 넷 · 월 둘 · 일 둘(ASCII 숫자만)
static final RegExp _pattern = RegExp(r'^([0-9]{4})-([0-9]{2})-([0-9]{2})$');
static const int _monthsInYear = 12;
/// 그달의 날 수 — 다음 달 0일(= 이달 마지막 날). UTC 로 만들어 시간대 · 서머타임과 상관없이 날만 센다
static int _daysInMonth(int year, int month) =>
DateTime.utc(year, month + 1, 0).day;
@override
bool operator ==(Object other) =>
other is CalendarDate &&
other.year == year &&
other.month == month &&
other.day == day;
@override
int get hashCode => Object.hash(year, month, day);
/// `YYYY-MM-DD` — 서버가 준 글과 같은 모양
@override
String toString() =>
'${year.toString().padLeft(4, '0')}-'
'${month.toString().padLeft(2, '0')}-'
'${day.toString().padLeft(2, '0')}';
}
@@ -2,8 +2,11 @@
///
/// `intl` 의 날짜 형식은 로캘 자료를 먼저 읽어야 해서(앱은 MaterialApp 이 읽는다) 순수 함수 시험에서 쓰기 번거롭다 —
/// 웹 `Intl.DateTimeFormat('ko-KR')` 이 내는 글 그대로를 여기서 만든다. 시각은 **기기 시간대**로 바꾼 값(`toLocal()`)을 받는다.
/// 달력 날짜([CalendarDate] — 시각이 아닌 "그날")는 시간대를 바꾸지 않고 그대로 쓴다.
library;
import 'package:arasystem_app/core/utils/calendar_date.dart';
/// 개수 — 1234 → "1,234" (웹 `formatCount` 와 같다)
String formatCount(int value) {
final String digits = value.abs().toString();
@@ -49,6 +52,11 @@ String koreanScheduleTime(DateTime local) =>
String koreanFullDateTime(DateTime local) =>
'${local.year}년 ${koreanScheduleTime(local)}';
/// 달력 날짜 — "2026년 11월 18일" (웹 `{ year: 'numeric', month: 'long', day: 'numeric' }` — 업데이트 내역의 쓴 날).
/// 시각이 아니라 시간대를 바꾸지 않는다
String koreanCalendarDate(CalendarDate date) =>
'${date.year}년 ${date.month}월 ${date.day}일';
/// 같은 달력 날인가 (둘 다 같은 시간대로 바꾼 값)
bool isSameCalendarDay(DateTime a, DateTime b) =>
a.year == b.year && a.month == b.month && a.day == b.day;
@@ -11,6 +11,7 @@ import 'package:arasystem_app/core/utils/korean_text.dart';
import 'package:arasystem_app/features/mail/data/models/mailbox.dart';
import 'package:arasystem_app/features/mail/domain/mail_body_notices.dart';
import 'package:arasystem_app/features/mail/domain/search_conditions.dart';
import 'package:arasystem_app/shared/widgets/load_state_texts.dart';
/// 쓰기 화면 (보드 `App1Compose`). 보드에 없는 상태(불러오기 · 받지 못함 · 칸 안내 · 첨부 상태 · 나가기 확인 · 보낸 뒤 알림)의 글은
/// 웹 상수(`frontend/src/shared/constants/mail.ts` · `mail-send-later.ts` · `large-file.ts`)를 그대로 옮겼다 — 앱에서 새로 쓴 글은 그 줄에 적었다
@@ -356,12 +357,12 @@ abstract final class MailListTexts {
/// 다음 쪽을 받는 동안 — 화면 낭독기
static const String loadingMore = '메일을 더 불러오는 중';
// ── 받지 못함 ──
static const String retry = '다시 시도';
// ── 받지 못함 — 글은 함께 쓰는 받지 못함 틀(`shared/widgets/load_states.dart`)의 글과 같다 ──
static const String retry = LoadStateTexts.retry;
/// 없는 메일함(주소에 남은 옛 id) — 다시 해도 같아 빠져나갈 단추를 준다
static const String toInbox = '받은편지함으로';
static String traceCode(String code) => '문의 코드 $code';
static String traceCode(String code) => LoadStateTexts.traceCode(code);
// ── 빈 메일함 ──
/// "휴지통이 비어 있습니다"
@@ -24,6 +24,7 @@ import 'package:arasystem_app/features/mail/presentation/widgets/mailbox_drawer.
import 'package:arasystem_app/features/mail/presentation/widgets/message_row.dart';
import 'package:arasystem_app/features/mail/presentation/widgets/push_offer_line.dart';
import 'package:arasystem_app/shared/widgets/confirm_dialog.dart';
import 'package:arasystem_app/shared/widgets/load_states.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
@@ -280,22 +281,24 @@ class _MailListBody extends StatelessWidget {
const Widget skeleton = _SkeletonList();
final Widget content = switch (list.status) {
_ when boxesFailure != null => _FillViewport(
child: MailListError(error: boxesFailure, onRetry: onRetryMailboxes),
child: LoadErrorView(error: boxesFailure, onRetry: onRetryMailboxes),
),
_ when unknownMailbox => _FillViewport(
child: MailListError(
child: LoadErrorView(
error: ApiException.fromCode('RES_001'),
onRetry: onRetry,
onLeave: onLeave,
leaveLabel: MailListTexts.toInbox,
),
),
_ when !mailboxesReady => skeleton,
MailListStatus.loading => skeleton,
MailListStatus.failed => _FillViewport(
child: MailListError(
child: LoadErrorView(
error: list.error!,
onRetry: onRetry,
onLeave: onLeave,
leaveLabel: MailListTexts.toInbox,
),
),
MailListStatus.ready when list.items.isEmpty => _FillViewport(
@@ -22,6 +22,7 @@ import 'package:arasystem_app/features/mail/presentation/widgets/mail_search_bar
import 'package:arasystem_app/features/mail/presentation/widgets/mail_search_conditions_sheet.dart';
import 'package:arasystem_app/features/mail/presentation/widgets/mail_search_states.dart';
import 'package:arasystem_app/features/mail/presentation/widgets/message_row.dart';
import 'package:arasystem_app/shared/widgets/load_states.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
@@ -273,7 +274,7 @@ class _SearchBody extends StatelessWidget {
final ApiException error =
search.error ?? ApiException.fromCode('SYS_001');
content = _FillViewport(
child: MailListError(
child: LoadErrorView(
error: error,
onRetry: onRetry,
// 찾던 메일함이 그사이 지워졌으면(RES_001) 다시 해도 같다 — 전체 메일함에서 찾는다
@@ -20,9 +20,9 @@ import 'package:arasystem_app/features/mail/presentation/providers/mail_toast_pr
import 'package:arasystem_app/features/mail/presentation/widgets/compose_bars.dart';
import 'package:arasystem_app/features/mail/presentation/widgets/compose_recipient_field.dart';
import 'package:arasystem_app/features/mail/presentation/widgets/compose_sections.dart';
import 'package:arasystem_app/features/mail/presentation/widgets/mail_list_states.dart';
import 'package:arasystem_app/features/mail/presentation/widgets/reader_notices.dart';
import 'package:arasystem_app/shared/widgets/confirm_dialog.dart';
import 'package:arasystem_app/shared/widgets/load_states.dart';
import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart';
import 'package:flutter/services.dart';
@@ -749,7 +749,7 @@ class _ComposeError extends StatelessWidget {
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: MailListError(
child: LoadErrorView(
error: error,
onRetry: onRetry,
onLeave: onLeave,
@@ -22,13 +22,13 @@ import 'package:arasystem_app/features/mail/presentation/providers/message_reade
import 'package:arasystem_app/features/mail/presentation/providers/message_reader_state.dart';
import 'package:arasystem_app/features/mail/presentation/widgets/mail_body_view.dart';
import 'package:arasystem_app/features/mail/presentation/widgets/mail_compose_area.dart';
import 'package:arasystem_app/features/mail/presentation/widgets/mail_list_states.dart';
import 'package:arasystem_app/features/mail/presentation/widgets/mail_move_sheet.dart';
import 'package:arasystem_app/features/mail/presentation/widgets/reader_attachments.dart';
import 'package:arasystem_app/features/mail/presentation/widgets/reader_bars.dart';
import 'package:arasystem_app/features/mail/presentation/widgets/reader_header.dart';
import 'package:arasystem_app/features/mail/presentation/widgets/reader_notices.dart';
import 'package:arasystem_app/shared/widgets/confirm_dialog.dart';
import 'package:arasystem_app/shared/widgets/load_states.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
@@ -533,7 +533,7 @@ class _ReaderError extends StatelessWidget {
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: MailListError(
child: LoadErrorView(
error: error,
onRetry: onRetry,
onLeave: onLeave,
@@ -1,4 +1,3 @@
import 'package:arasystem_app/core/api/api_exception.dart';
import 'package:arasystem_app/core/theme/app_spacing.dart';
import 'package:arasystem_app/core/theme/app_theme.dart';
import 'package:arasystem_app/core/theme/app_typography.dart';
@@ -6,17 +5,15 @@ import 'package:arasystem_app/features/mail/data/models/mailbox.dart';
import 'package:arasystem_app/features/mail/presentation/mail_texts.dart';
import 'package:arasystem_app/features/mail/presentation/widgets/mailbox_icons.dart';
import 'package:arasystem_app/shared/widgets/keep_all_text.dart';
import 'package:arasystem_app/shared/widgets/load_states.dart';
import 'package:flutter/material.dart';
/// 문의 코드로 보이는 추적 ID 앞자리 수 — 웹 `TRACE_CODE_LENGTH` 와 같다
const int kTraceCodeLength = 8;
// 가운데 상태의 틀([StateLayout]) · 받지 못함([LoadErrorView]) · 뼈대 막대([SkeletonBar])는 업데이트 내역과 함께 쓰려고
// `shared/widgets/load_states.dart` 로 옮겼다(모양 · 동작 그대로). 여기에는 메일 목록에만 쓰는 상태가 남는다.
/// 처음 불러오는 동안의 뼈대 줄 수 — 휴대폰 한 화면쯤
const int _skeletonRows = 8;
/// 빈 메일함 · 받지 못함의 아래 여백 — 안내가 화면 가운데보다 조금 위에 오게(보드의 `padding-bottom: 120px`)
const double _stateBottomSpace = 120;
/// 뼈대 줄의 막대 폭 — 보낸 사람 · 제목(미리 보기 막대는 줄 폭을 다 쓴다)
const double _skeletonWhoWidth = 0.4;
const double _skeletonSubjectWidth = 0.8;
@@ -40,7 +37,7 @@ class MailListEmpty extends StatelessWidget {
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
return MailStateLayout(
return StateLayout(
icon: mailboxIconOf(role),
iconColor: extra.textMuted,
iconBackground: theme.colorScheme.surfaceContainerHigh,
@@ -58,139 +55,6 @@ class MailListEmpty extends StatelessWidget {
}
}
/// 받지 못함 — 사전 문구 · 문의 코드 · [다시 시도](없는 메일함은 [받은편지함으로] — 다시 해도 같다)
/// (웹 `MailLoadError` 와 같은 결 — 문구는 에러 코드 사전에서만 온다). 읽기 화면도 쓴다(없는 메일은 [목록으로]),
/// 검색 화면도 쓴다(찾던 메일함이 지워졌으면 [전체 메일함에서 찾기] — 제목은 보드 `App1SearchStates` 대로 작게)
class MailListError extends StatelessWidget {
const MailListError({
required this.error,
required this.onRetry,
this.onLeave,
this.leaveLabel = MailListTexts.toInbox,
this.titleStyle,
super.key,
});
final ApiException error;
final VoidCallback onRetry;
/// 없는 메일함(`RES_001`)일 때 갈 곳 — 없으면 늘 [다시 시도]
final VoidCallback? onLeave;
/// [onLeave] 단추의 이름
final String leaveLabel;
/// 제목 글자 — 없으면 [MailStateLayout] 의 기본(목록 보드의 크기)
final TextStyle? titleStyle;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
final VoidCallback? leave = onLeave;
final bool notFound = error.code == 'RES_001' && leave != null;
final String? trace = traceCodeOf(error.traceId);
return MailStateLayout(
icon: Icons.error_outline,
iconColor: theme.colorScheme.error,
iconBackground: theme.colorScheme.errorContainer,
title: error.message,
titleStyle: titleStyle,
children: <Widget>[
if (trace != null)
Text(
MailListTexts.traceCode(trace),
style: theme.textTheme.bodySmall?.copyWith(color: extra.textMuted),
),
TextButton(
onPressed: notFound ? leave : onRetry,
child: Text(notFound ? leaveLabel : MailListTexts.retry),
),
],
);
}
}
/// 추적 ID → 화면의 문의 코드(앞 8자리). 없으면 null
String? traceCodeOf(String? traceId) {
final String? value = traceId?.trim();
if (value == null || value.isEmpty) return null;
return value.length > kTraceCodeLength
? value.substring(0, kTraceCodeLength)
: value;
}
/// 가운데 상태 화면의 틀 — 아이콘 원(72) · 제목(머리) · 그 아래 글 · 단추. 화면 가운데보다 조금 위에 온다.
/// 목록의 빈 메일함 · 받지 못함, 검색의 결과 없음 · 아직 못 찾음 · 찾을 말이 너무 많음이 쓴다
class MailStateLayout extends StatelessWidget {
const MailStateLayout({
required this.icon,
required this.iconColor,
required this.iconBackground,
required this.title,
required this.children,
this.titleStyle,
super.key,
});
final IconData icon;
final Color iconColor;
final Color iconBackground;
final String title;
final List<Widget> children;
/// 제목 글자 — 없으면 `titleMedium`(목록 보드 17 → 18). 검색 보드는 15(`titleSmall`)
final TextStyle? titleStyle;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.fromLTRB(
AppSpacing.s40,
0,
AppSpacing.s40,
_stateBottomSpace,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
ExcludeSemantics(
child: Container(
width: AppSize.stateIcon,
height: AppSize.stateIcon,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: iconBackground,
),
child: Icon(icon, size: AppSize.stateIconGlyph, color: iconColor),
),
),
const SizedBox(height: AppSpacing.s16),
// 여러 줄(긴 사전 문구)이어도 가운데에 — 글 자체를 가운데 맞춘다
DefaultTextStyle.merge(
textAlign: TextAlign.center,
child: Semantics(
header: true,
child: KeepAllText(
title,
style:
titleStyle ??
theme.textTheme.titleMedium?.copyWith(
fontWeight: AppTypography.semiBold,
),
),
),
),
const SizedBox(height: AppSpacing.s8),
for (final Widget child in children)
DefaultTextStyle.merge(textAlign: TextAlign.center, child: child),
],
),
);
}
}
/// 처음 불러오는 동안의 뼈대 — 줄 모양의 회색 막대(화면 낭독기는 "메일 목록 불러오는 중" — 검색은 "찾는 중")
class MailListSkeleton extends StatelessWidget {
const MailListSkeleton({this.label = MailListTexts.loading, super.key});
@@ -201,72 +65,52 @@ class MailListSkeleton extends StatelessWidget {
@override
Widget build(BuildContext context) {
final Color bar = Theme.of(context).colorScheme.surfaceContainerHighest;
return Semantics(
return LoadingSkeleton(
label: label,
liveRegion: true,
child: ExcludeSemantics(
child: Column(
children: <Widget>[
for (int index = 0; index < _skeletonRows; index++)
Padding(
padding: const EdgeInsets.fromLTRB(
AppSpacing.s12,
AppSpacing.s14,
AppSpacing.s16,
AppSpacing.s14,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Container(
width: AppSize.avatar,
height: AppSize.avatar,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: bar,
),
),
const SizedBox(width: AppSpacing.s10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
FractionallySizedBox(
widthFactor: _skeletonWhoWidth,
child: _SkeletonBar(color: bar),
),
const SizedBox(height: AppSpacing.s8),
FractionallySizedBox(
widthFactor: _skeletonSubjectWidth,
child: _SkeletonBar(color: bar),
),
const SizedBox(height: AppSpacing.s8),
_SkeletonBar(color: bar),
],
),
),
],
),
child: Column(
children: <Widget>[
for (int index = 0; index < _skeletonRows; index++)
Padding(
padding: const EdgeInsets.fromLTRB(
AppSpacing.s12,
AppSpacing.s14,
AppSpacing.s16,
AppSpacing.s14,
),
],
),
),
);
}
}
class _SkeletonBar extends StatelessWidget {
const _SkeletonBar({required this.color});
final Color color;
@override
Widget build(BuildContext context) {
return Container(
height: AppSpacing.s10,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(AppRadius.xs),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Container(
width: AppSize.avatar,
height: AppSize.avatar,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: bar,
),
),
const SizedBox(width: AppSpacing.s10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
FractionallySizedBox(
widthFactor: _skeletonWhoWidth,
child: SkeletonBar(color: bar),
),
const SizedBox(height: AppSpacing.s8),
FractionallySizedBox(
widthFactor: _skeletonSubjectWidth,
child: SkeletonBar(color: bar),
),
const SizedBox(height: AppSpacing.s8),
SkeletonBar(color: bar),
],
),
),
],
),
),
],
),
);
}
@@ -2,12 +2,12 @@ import 'package:arasystem_app/core/theme/app_spacing.dart';
import 'package:arasystem_app/core/theme/app_theme.dart';
import 'package:arasystem_app/core/theme/app_typography.dart';
import 'package:arasystem_app/features/mail/presentation/mail_texts.dart';
import 'package:arasystem_app/features/mail/presentation/widgets/mail_list_states.dart';
import 'package:arasystem_app/shared/widgets/keep_all_text.dart';
import 'package:arasystem_app/shared/widgets/load_states.dart';
import 'package:flutter/material.dart';
/// 검색 화면의 상태 글 — 찾기 전 · 결과 없음 · 아직 못 찾음 · 찾을 말이 너무 많음 · 목록 끝의 [더 찾기] (보드 `App1Search` · `App1SearchStates`).
/// 가운데 상태는 목록과 같은 틀([MailStateLayout])이고, 제목은 보드대로 15(`titleSmall`) · 도움말 13(`bodySmall`)이다
/// 가운데 상태는 목록과 같은 틀([StateLayout] — `shared/widgets/load_states.dart`)이고, 제목은 보드대로 15(`titleSmall`) · 도움말 13(`bodySmall`)이다
TextStyle? _stateTitleStyle(ThemeData theme) =>
theme.textTheme.titleSmall?.copyWith(fontWeight: AppTypography.semiBold);
@@ -71,7 +71,7 @@ class MailSearchEmpty extends StatelessWidget {
: query.isEmpty
? MailSearchTexts.emptyConditionsHint
: MailSearchTexts.emptyHint;
return MailStateLayout(
return StateLayout(
icon: Icons.search,
iconColor: extra.textMuted,
iconBackground: theme.colorScheme.surfaceContainerHigh,
@@ -114,7 +114,7 @@ class MailSearchPartial extends StatelessWidget {
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
return MailStateLayout(
return StateLayout(
icon: Icons.search,
iconColor: extra.textMuted,
iconBackground: theme.colorScheme.surfaceContainerHigh,
@@ -139,7 +139,7 @@ class MailSearchTooManyWords extends StatelessWidget {
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
return MailStateLayout(
return StateLayout(
icon: Icons.error_outline,
iconColor: extra.textMuted,
iconBackground: theme.colorScheme.surfaceContainerHigh,
@@ -0,0 +1,190 @@
import 'package:arasystem_app/core/utils/calendar_date.dart';
import 'package:flutter/foundation.dart';
/// 업데이트 내역 글의 머리 — 목록 줄 · 글 화면의 다른 글 줄 (계약 7절 "업데이트 내역" — `{ id, date, title }`).
///
/// 모델은 손으로 쓴다(생성기 없음 — flutter 스킬 "모델 생성기"). 글은 서버가 다듬은 글 그대로다 — 마크다운 해석 없이 글로만 그린다.
@immutable
class ReleaseNoteSummary {
const ReleaseNoteSummary({
required this.id,
required this.date,
required this.title,
});
/// 글 id — 글 파일 이름(`2026-11-18` · 같은 날 둘째부터 `2026-11-18-2`). 해석하지 않고 주소 · 요청에 그대로 쓴다
final String id;
/// 쓴 날 — 달력 날짜(시각이 아니다 — 시간대를 바꾸지 않고 "2026년 11월 18일"로 쓴다)
final CalendarDate date;
final String title;
/// 모양이 틀리면(빈 id · 날짜가 `YYYY-MM-DD` 가 아님 · 없는 날 포함) null
static ReleaseNoteSummary? tryParse(Object? json) {
if (json is! Map<String, Object?>) return null;
final Object? id = json['id'];
final Object? title = json['title'];
final CalendarDate? date = CalendarDate.tryParse(json['date']);
if (id is! String || id.isEmpty || title is! String || date == null) {
return null;
}
return ReleaseNoteSummary(id: id, date: date, title: title);
}
@override
bool operator ==(Object other) =>
other is ReleaseNoteSummary &&
other.id == id &&
other.date == date &&
other.title == title;
@override
int get hashCode => Object.hash(id, date, title);
}
/// 글의 묶음 — 머리(`## 머리` — 머리 없이 쓴 묶음은 null)와 줄(`- 한 줄`)
@immutable
class ReleaseNoteSection {
const ReleaseNoteSection({required this.heading, required this.items});
final String? heading;
final List<String> items;
/// 모양이 틀리면 null
static ReleaseNoteSection? tryParse(Object? json) {
if (json is! Map<String, Object?>) return null;
final Object? heading = json['heading'];
final Object? items = json['items'];
if ((heading != null && heading is! String) || items is! List<Object?>) {
return null;
}
final List<String> lines = <String>[];
for (final Object? item in items) {
if (item is! String) return null;
lines.add(item);
}
return ReleaseNoteSection(
heading: heading as String?,
items: List<String>.unmodifiable(lines),
);
}
@override
bool operator ==(Object other) =>
other is ReleaseNoteSection &&
other.heading == heading &&
listEquals(other.items, items);
@override
int get hashCode => Object.hash(heading, Object.hashAll(items));
}
/// 업데이트 내역 글 하나 — `GET /api/release-notes/{id}` 의 `{ id, date, title, sections, previous, next }`
@immutable
class ReleaseNote {
const ReleaseNote({
required this.id,
required this.date,
required this.title,
required this.sections,
this.previous,
this.next,
});
final String id;
final CalendarDate date;
final String title;
final List<ReleaseNoteSection> sections;
/// 바로 더 옛 글(목록에서 아래) — 없으면 null. 글 화면 끝의 "이전 글" 줄
final ReleaseNoteSummary? previous;
/// 바로 더 새 글(목록에서 위) — 없으면 null. 글 화면 끝의 "다음 글" 줄
final ReleaseNoteSummary? next;
/// 모양이 틀리면(묶음 · 다른 글 하나라도) null. `previous` · `next` 칸이 아예 없으면 없는 것(null)으로 본다
static ReleaseNote? tryParse(Object? json) {
if (json is! Map<String, Object?>) return null;
final ReleaseNoteSummary? head = ReleaseNoteSummary.tryParse(json);
final Object? rawSections = json['sections'];
if (head == null || rawSections is! List<Object?>) return null;
final List<ReleaseNoteSection> sections = <ReleaseNoteSection>[];
for (final Object? raw in rawSections) {
final ReleaseNoteSection? section = ReleaseNoteSection.tryParse(raw);
if (section == null) return null;
sections.add(section);
}
final Object? rawPrevious = json['previous'];
final Object? rawNext = json['next'];
final ReleaseNoteSummary? previous = ReleaseNoteSummary.tryParse(
rawPrevious,
);
final ReleaseNoteSummary? next = ReleaseNoteSummary.tryParse(rawNext);
if ((rawPrevious != null && previous == null) ||
(rawNext != null && next == null)) {
return null;
}
return ReleaseNote(
id: head.id,
date: head.date,
title: head.title,
sections: List<ReleaseNoteSection>.unmodifiable(sections),
previous: previous,
next: next,
);
}
}
/// 목록 한 쪽 — `{ items, pageInfo }` (응답 포맷 규칙 "목록 응답" — 최근 글부터)
@immutable
class ReleaseNotesPage {
const ReleaseNotesPage({
required this.items,
required this.hasNext,
this.nextCursor,
this.total,
});
final List<ReleaseNoteSummary> items;
final bool hasNext;
/// 다음 쪽 커서 — 해석하지 않고 그대로 다시 보낸다. 마지막 쪽이면 null
final String? nextCursor;
/// 글 수 — 서버가 세지 못하면 null
final int? total;
/// 다음 쪽이 있는가 — `hasNext` 이고 커서가 있을 때만
bool get hasMore => hasNext && (nextCursor?.isNotEmpty ?? false);
/// 모양이 틀리면(줄 하나라도) null
static ReleaseNotesPage? tryParse(Object? json) {
if (json is! Map<String, Object?>) return null;
final Object? rawItems = json['items'];
final Object? pageInfo = json['pageInfo'];
if (rawItems is! List<Object?> || pageInfo is! Map<String, Object?>) {
return null;
}
final Object? hasNext = pageInfo['hasNext'];
final Object? nextCursor = pageInfo['nextCursor'];
final Object? total = pageInfo['total'];
if (hasNext is! bool ||
(nextCursor != null && nextCursor is! String) ||
(total != null && total is! num)) {
return null;
}
final List<ReleaseNoteSummary> items = <ReleaseNoteSummary>[];
for (final Object? raw in rawItems) {
final ReleaseNoteSummary? item = ReleaseNoteSummary.tryParse(raw);
if (item == null) return null;
items.add(item);
}
return ReleaseNotesPage(
items: List<ReleaseNoteSummary>.unmodifiable(items),
hasNext: hasNext,
nextCursor: nextCursor as String?,
total: (total as num?)?.toInt(),
);
}
}
@@ -0,0 +1,66 @@
import 'package:arasystem_app/core/api/api_error_mapper.dart';
import 'package:arasystem_app/core/api/api_exception.dart';
import 'package:arasystem_app/core/api/request_flags.dart';
import 'package:arasystem_app/features/settings/data/models/release_note.dart';
import 'package:dio/dio.dart';
/// 업데이트 내역 API 경로 — baseUrl(`…/api`) 뒤에 붙는다(계약 7절 "업데이트 내역"). 경로는 대소문자를 가린다
abstract final class ReleaseNotesApiPaths {
static const String releaseNotes = '/release-notes';
/// 글 하나 — id 는 경로 칸으로 인코딩한다(서버는 모양 · 길이만 보고 파일 경로로 쓰지 않는다)
static String releaseNote(String id) =>
'$releaseNotes/${Uri.encodeComponent(id)}';
}
/// 목록 한 쪽의 수 — 계약 기본값(20)과 같다(보드 "처음 20개 · [더 보기]로 20개씩")
const int kReleaseNotesPageSize = 20;
/// 업데이트 내역 API — 계약 7절. 웹과 같은 경로를 Bearer 로 부른다(로그인한 직원만 — 제한된 세션은 `AUTH_010`).
/// 올리는 · 고치는 API 는 없다(글은 운영자가 저장소의 글 파일로 쓴다).
///
/// 응답 언래핑 · 에러 코드 · 알림은 인터셉터가 맡는다 — 여기서는 읽기만 하고 문구를 만들지 않는다.
/// 실패는 늘 `DioException`(`error` 에 `ApiException`)이다 — 응답 모양이 계약과 다르면 [ReleaseNotesMalformedResponse](`SYS_001`).
class ReleaseNotesRepository {
const ReleaseNotesRepository(this._dio);
final Dio _dio;
/// 목록 한 쪽 — 최근 글부터. [cursor] 는 해석하지 않고 그대로 보낸다.
/// [silent] 면 실패를 알리지 않는다(첫 쪽 · [다시 시도]는 화면이 받지 못함을 그린다 — [더 보기]는 알린다)
Future<ReleaseNotesPage> listNotes({
String? cursor,
int limit = kReleaseNotesPageSize,
bool silent = false,
}) async {
final Response<Object?> response = await _dio.get<Object?>(
ReleaseNotesApiPaths.releaseNotes,
queryParameters: <String, Object?>{'limit': limit, 'cursor': ?cursor},
options: silent ? ApiOptions.silent() : null,
);
final ReleaseNotesPage? page = ReleaseNotesPage.tryParse(response.data);
if (page == null) throw ReleaseNotesMalformedResponse(response);
return page;
}
/// 글 하나 — 알림은 띄우지 않는다(화면이 없는 글 · 받지 못함을 그린다). 없는 글 · 모양이 틀린 id 는 404 `RES_001`
Future<ReleaseNote> getNote(String id) async {
final Response<Object?> response = await _dio.get<Object?>(
ReleaseNotesApiPaths.releaseNote(id),
options: ApiOptions.silent(),
);
final ReleaseNote? note = ReleaseNote.tryParse(response.data);
if (note == null) throw ReleaseNotesMalformedResponse(response);
return note;
}
}
/// 응답은 왔는데 모양이 계약과 다르다(`SYS_001`) — 응답이 인터셉터를 다 지난 뒤라 아무도 알리지 않았다.
/// 알림을 띄우는 요청([더 보기])이면 부르는 쪽이 알린다(메일의 `DownloadInterrupted` 와 같은 결)
class ReleaseNotesMalformedResponse extends DioException {
ReleaseNotesMalformedResponse(Response<Object?> response)
: super(
requestOptions: response.requestOptions,
error: ApiException.fromCode(kFallbackCode),
);
}
@@ -0,0 +1,428 @@
import 'dart:async';
import 'package:arasystem_app/core/api/api_exception.dart';
import 'package:arasystem_app/core/theme/app_spacing.dart';
import 'package:arasystem_app/core/theme/app_theme.dart';
import 'package:arasystem_app/core/theme/app_typography.dart';
import 'package:arasystem_app/core/utils/korean_format.dart';
import 'package:arasystem_app/features/settings/data/models/release_note.dart';
import 'package:arasystem_app/features/settings/presentation/providers/release_note_controller.dart';
import 'package:arasystem_app/features/settings/presentation/release_notes_texts.dart';
import 'package:arasystem_app/features/settings/presentation/widgets/release_notes_parts.dart';
import 'package:arasystem_app/shared/widgets/keep_all_text.dart';
import 'package:arasystem_app/shared/widgets/load_states.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
/// 글을 받는 동안 — 제목 막대 폭과 줄 막대 폭(보드에는 그리지 않았다 — 목록 막대와 같은 결)
const double _skeletonTitleWidth = 0.78;
const double _skeletonHeadingWidth = 0.32;
const List<double> _skeletonLineWidths = <double>[1, 0.86, 0.94, 0.6];
/// 업데이트 내역 글 — 보드 `ReleaseNotes` B3(맨 위 글) · B4(가운데 글) · B5(없는 글) · B6(받지 못함).
/// 목록 위에 쌓이는 전체 화면이다(탭 막대 없음 — 주소로 바로 열어도 목록이 아래에 있다).
///
/// - 위 막대는 뒤로만이고 늘 목록으로 간다. 날짜(13) · 제목(18) · 묶음(머리 15 · 점 목록 15/1.6) 차례이고 길면 화면째 스크롤한다.
/// - 끝에 다른 글 줄(64) — 다음 글(더 새 글)이 위, 이전 글(더 옛 글)이 아래(목록과 같은 차례), 없는 쪽은 줄이 없다.
/// 누르면 이 글을 그 글로 바꿔 끼운다(쌓지 않는다 — 뒤로는 늘 목록). [목록] 단추는 두지 않는다.
/// - 받는 동안 글 자리를 막대로 잡아 두고, 없는 글(지워졌거나 주소가 틀림 — 404 `RES_001`)은 사전 문구 · 문의 코드 · [목록으로],
/// 그 밖의 실패는 사전 문구 · 문의 코드 · [다시 시도]다(알림 없음).
class ReleaseNotePage extends ConsumerWidget {
const ReleaseNotePage({
required this.noteId,
required this.noteLocation,
required this.listLocation,
super.key,
});
/// 주소의 글 id
final String noteId;
/// 글 화면의 경로 — 다른 글 줄이 쓴다
final String Function(String noteId) noteLocation;
/// 목록의 경로 — 아래에 쌓인 화면이 없을 때 뒤로 · [목록으로]가 간다
final String listLocation;
/// 목록으로 — 쌓인 목록으로 돌아간다(없으면 목록 주소로)
void _toList(BuildContext context) {
if (context.canPop()) {
context.pop();
} else {
context.go(listLocation);
}
}
/// 다른 글로 — 이 글을 바꿔 끼운다(뒤로는 늘 목록 — 보드 "앱은 위 막대의 뒤로가 늘 목록")
void _open(BuildContext context, ReleaseNoteSummary note) {
context.pushReplacement(noteLocation(note.id));
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final ReleaseNoteState state = ref.watch(releaseNoteProvider(noteId));
final ReleaseNoteController controller = ref.read(
releaseNoteProvider(noteId).notifier,
);
final ReleaseNote? note = state.note;
return Scaffold(
appBar: ReleaseNotesAppBar(onBack: () => _toList(context)),
body: switch (state.status) {
ReleaseNoteStatus.ready when note != null => _NoteView(
note: note,
onOpen: (ReleaseNoteSummary other) => _open(context, other),
),
ReleaseNoteStatus.failed => ReleaseNotesStateFill(
child: LoadErrorView(
error: state.error ?? ApiException.fromCode('SYS_001'),
onRetry: () => unawaited(controller.retry()),
onLeave: () => _toList(context),
leaveLabel: ReleaseNotesTexts.toList,
titleStyle: releaseNotesErrorTitleStyle(Theme.of(context)),
),
),
_ => const _NoteSkeleton(),
},
);
}
}
/// 받은 글 — 글 + 끝의 다른 글 줄(화면째 스크롤)
class _NoteView extends StatelessWidget {
const _NoteView({required this.note, required this.onOpen});
final ReleaseNote note;
final ValueChanged<ReleaseNoteSummary> onOpen;
@override
Widget build(BuildContext context) {
final ReleaseNoteSummary? newer = note.next;
final ReleaseNoteSummary? older = note.previous;
return ListView(
children: <Widget>[
_Article(note: note),
if (newer != null || older != null)
_NeighborLinks(newer: newer, older: older, onOpen: onOpen),
],
);
}
}
/// 글 — 날짜 · 제목 · 묶음 (보드 B3: 둘레 위 20 · 옆 20 · 아래 24, 덩이 사이 20)
class _Article extends StatelessWidget {
const _Article({required this.note});
final ReleaseNote note;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.fromLTRB(
AppSpacing.s20,
AppSpacing.s20,
AppSpacing.s20,
AppSpacing.s24,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
ReleaseNoteDateText(
note.date,
style: theme.textTheme.bodySmall?.copyWith(
fontWeight: AppTypography.medium,
),
),
const SizedBox(height: AppSpacing.s4),
Semantics(
header: true,
child: KeepAllText(
note.title,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: AppTypography.bold,
),
),
),
for (final ReleaseNoteSection section in note.sections) ...<Widget>[
const SizedBox(height: AppSpacing.s20),
_Section(section: section),
],
],
),
);
}
}
/// 묶음 — 머리(있으면 15 · 굵게) · 점 목록(줄 사이 6)
class _Section extends StatelessWidget {
const _Section({required this.section});
final ReleaseNoteSection section;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final String? heading = section.heading;
final List<String> items = section.items;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
if (heading != null) ...<Widget>[
Semantics(
header: true,
child: KeepAllText(
heading,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: AppTypography.bold,
color: theme.colorScheme.onSurface,
),
),
),
const SizedBox(height: AppSpacing.s8),
],
for (int index = 0; index < items.length; index++) ...<Widget>[
if (index > 0) const SizedBox(height: AppSpacing.s6),
_Item(text: items[index]),
],
],
);
}
}
/// 점 목록의 한 줄 — 앞에 회색 점(5)이 첫 줄 가운데에 오고, 넘친 줄은 점 칸(16) 뒤에서 이어진다
class _Item extends StatelessWidget {
const _Item({required this.text});
final String text;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
final TextStyle? style = theme.textTheme.bodyMedium?.copyWith(
color: extra.textStrong,
height: AppTypography.lineHeightRelaxed,
);
// 첫 줄의 높이 — 기기 글자 크기 설정을 따라 커져도 점이 첫 줄 가운데에 온다
final double lineHeight =
MediaQuery.textScalerOf(
context,
).scale(style?.fontSize ?? AppTypography.bodyMedium) *
AppTypography.lineHeightRelaxed;
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
ExcludeSemantics(
child: SizedBox(
width: AppSpacing.s16,
height: lineHeight,
child: Align(
alignment: Alignment.centerLeft,
child: Container(
width: AppSize.bulletDot,
height: AppSize.bulletDot,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: extra.iconMuted,
),
),
),
),
),
Expanded(child: KeepAllText(text, style: style)),
],
);
}
}
/// 다른 글 줄 묶음 — 다음 글(위) · 이전 글(아래), 위아래 선과 줄 사이 선(보드 B3 · B4)
class _NeighborLinks extends StatelessWidget {
const _NeighborLinks({
required this.newer,
required this.older,
required this.onOpen,
});
/// 바로 더 새 글 — 없으면 null
final ReleaseNoteSummary? newer;
/// 바로 더 옛 글 — 없으면 null
final ReleaseNoteSummary? older;
final ValueChanged<ReleaseNoteSummary> onOpen;
@override
Widget build(BuildContext context) {
final Color line = Theme.of(
context,
).extension<AppExtraColors>()!.borderSubtle;
final ReleaseNoteSummary? newerNote = newer;
final ReleaseNoteSummary? olderNote = older;
final BorderSide side = BorderSide(color: line, width: AppSize.borderWidth);
return DecoratedBox(
decoration: BoxDecoration(
border: Border(top: side, bottom: side),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
if (newerNote != null)
_NeighborRow(
label: ReleaseNotesTexts.newer,
note: newerNote,
onTap: () => onOpen(newerNote),
),
if (newerNote != null && olderNote != null)
Divider(height: AppSize.borderWidth, color: line),
if (olderNote != null)
_NeighborRow(
label: ReleaseNotesTexts.older,
note: olderNote,
onTap: () => onOpen(olderNote),
),
],
),
);
}
}
/// 다른 글 줄 — "다음 글 · 2026년 11월 18일"(13) · 제목(15) · › (최소 64 · 줄 전체를 누른다).
/// 화면 낭독기는 "다음 글, 2026년 11월 18일, 제목" 단추로 읽는다
class _NeighborRow extends StatelessWidget {
const _NeighborRow({
required this.label,
required this.note,
required this.onTap,
});
/// "다음 글" · "이전 글"
final String label;
final ReleaseNoteSummary note;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
final String date = koreanCalendarDate(note.date);
return Semantics(
container: true,
button: true,
child: InkWell(
onTap: onTap,
child: ConstrainedBox(
constraints: const BoxConstraints(
minHeight: AppSize.noteLinkRowMinHeight,
),
child: Padding(
padding: const EdgeInsetsDirectional.fromSTEB(
AppSpacing.s20,
AppSpacing.s10,
AppSpacing.s12,
AppSpacing.s10,
),
child: Row(
spacing: AppSpacing.s12,
children: <Widget>[
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: AppSpacing.s2,
children: <Widget>[
Text(
ReleaseNotesTexts.neighborLine(label, date),
semanticsLabel: ReleaseNotesTexts.neighborLineSpoken(
label,
date,
),
style: theme.textTheme.bodySmall?.copyWith(
color: extra.textMuted,
height: AppTypography.lineHeightHeading,
fontFeatures: const <FontFeature>[
FontFeature.tabularFigures(),
],
),
),
KeepAllText(
note.title,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: AppTypography.medium,
height: AppTypography.lineHeightHeading,
),
),
],
),
),
ExcludeSemantics(
child: Icon(
Icons.chevron_right,
size: AppSize.iconMedium,
color: extra.textMuted,
),
),
],
),
),
),
),
);
}
}
/// 글을 받는 동안 — 날짜 · 제목 · 머리 · 줄 자리의 막대(목록 막대와 같은 결 — 끝이 둥글다). 화면 낭독기는 "글 불러오는 중"
class _NoteSkeleton extends StatelessWidget {
const _NoteSkeleton();
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final Color bar = theme.colorScheme.surfaceContainerHighest;
final Color light = theme.extension<AppExtraColors>()!.borderSubtle;
return LoadingSkeleton(
label: ReleaseNotesTexts.noteLoading,
child: SingleChildScrollView(
physics: const NeverScrollableScrollPhysics(),
padding: const EdgeInsets.fromLTRB(
AppSpacing.s20,
AppSpacing.s20,
AppSpacing.s20,
AppSpacing.s24,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
SizedBox(
width: AppSize.skeletonDateBarWidth,
child: SkeletonBar(color: light, rounded: true),
),
const SizedBox(height: AppSpacing.s10),
FractionallySizedBox(
widthFactor: _skeletonTitleWidth,
child: SkeletonBar(
color: bar,
height: AppSize.skeletonTitleBarHeight,
rounded: true,
),
),
const SizedBox(height: AppSpacing.s28),
FractionallySizedBox(
widthFactor: _skeletonHeadingWidth,
child: SkeletonBar(
color: bar,
height: AppSize.skeletonTitleBarHeight,
rounded: true,
),
),
for (final double width in _skeletonLineWidths) ...<Widget>[
const SizedBox(height: AppSpacing.s14),
FractionallySizedBox(
widthFactor: width,
child: SkeletonBar(color: bar, rounded: true),
),
],
],
),
),
);
}
}
@@ -0,0 +1,350 @@
import 'dart:async';
import 'package:arasystem_app/core/api/api_exception.dart';
import 'package:arasystem_app/core/theme/app_colors.dart';
import 'package:arasystem_app/core/theme/app_spacing.dart';
import 'package:arasystem_app/core/theme/app_theme.dart';
import 'package:arasystem_app/core/theme/app_typography.dart';
import 'package:arasystem_app/features/settings/data/models/release_note.dart';
import 'package:arasystem_app/features/settings/presentation/providers/release_notes_list_controller.dart';
import 'package:arasystem_app/features/settings/presentation/release_notes_texts.dart';
import 'package:arasystem_app/features/settings/presentation/widgets/release_notes_parts.dart';
import 'package:arasystem_app/shared/widgets/keep_all_text.dart';
import 'package:arasystem_app/shared/widgets/load_states.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
/// 불러오는 동안 줄의 제목 막대 폭 — 보드 B7 의 여섯 줄
const List<double> _skeletonTitleWidths = <double>[
0.78,
0.56,
0.84,
0.48,
0.7,
0.6,
];
/// 업데이트 내역 목록 — 보드 `ReleaseNotes` B2(목록) · B7(불러오는 중) · B8(받지 못함) · B9(글이 없을 때).
/// 설정 탭 › 앱 정보의 [업데이트 내역]이 여는 전체 화면이다(탭 막대 없음).
///
/// - 위 막대: 뒤로 · "업데이트 내역". 줄마다 제목(16) · 날짜(13) · ›(줄 전체를 누른다, 68) — 누르면 글 화면을 위에 쌓는다.
/// - 최근 글부터 20개, 더 있으면 목록 아래 [더 보기](폭 전체 48 — 받는 동안 그 단추만 바쁨 표시).
/// 더 받지 못하면 알림으로 알리고 받은 줄은 그대로다.
/// - 받는 동안 줄 자리를 막대로 잡아 두고, 받지 못하면 그 자리에 사전 문구 · 문의 코드 · [다시 시도](알림 없음),
/// 글이 없으면 "아직 알려 드릴 업데이트가 없습니다." 한 줄이다.
class ReleaseNotesPage extends ConsumerWidget {
const ReleaseNotesPage({
required this.noteLocation,
required this.exitLocation,
super.key,
});
/// 글 화면의 경로 — `/settings/release-notes/<id>`
final String Function(String noteId) noteLocation;
/// 뒤로 갈 화면이 쌓여 있지 않을 때(주소로 바로 연 목록) 갈 곳 — 설정
final String exitLocation;
void _leave(BuildContext context) {
if (context.canPop()) {
context.pop();
} else {
context.go(exitLocation);
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final ReleaseNotesListState list = ref.watch(releaseNotesListProvider);
final ReleaseNotesListController controller = ref.read(
releaseNotesListProvider.notifier,
);
return Scaffold(
appBar: ReleaseNotesAppBar(
title: ReleaseNotesTexts.title,
onBack: () => _leave(context),
),
body: switch (list.status) {
ReleaseNotesListStatus.loading => const _ListSkeleton(),
ReleaseNotesListStatus.failed => ReleaseNotesStateFill(
child: LoadErrorView(
error: list.error ?? ApiException.fromCode('SYS_001'),
onRetry: () => unawaited(controller.retry()),
titleStyle: releaseNotesErrorTitleStyle(Theme.of(context)),
),
),
ReleaseNotesListStatus.ready when list.items.isEmpty =>
const ReleaseNotesStateFill(child: _EmptyList()),
ReleaseNotesListStatus.ready => _NoteList(
list: list,
onOpen: (ReleaseNoteSummary note) =>
unawaited(context.push<void>(noteLocation(note.id))),
onMore: () => unawaited(controller.loadMore()),
),
},
);
}
}
/// 받은 목록 — 줄 + (더 있으면) 끝의 [더 보기]
class _NoteList extends StatelessWidget {
const _NoteList({
required this.list,
required this.onOpen,
required this.onMore,
});
final ReleaseNotesListState list;
final ValueChanged<ReleaseNoteSummary> onOpen;
final VoidCallback onMore;
@override
Widget build(BuildContext context) {
final List<ReleaseNoteSummary> items = list.items;
return ListView.builder(
itemCount: items.length + (list.hasMore ? 1 : 0),
itemBuilder: (BuildContext context, int index) {
if (index == items.length) {
return _MoreButton(busy: list.loadingMore, onPressed: onMore);
}
final ReleaseNoteSummary note = items[index];
return _NoteRow(note: note, onTap: () => onOpen(note));
},
);
}
}
/// 목록 줄 — 제목 · 날짜 · › (보드 B2: 최소 68 · 왼쪽 20 · 오른쪽 12, 아래 선). 화면 낭독기는 "제목, 날짜" 단추로 읽는다
class _NoteRow extends StatelessWidget {
const _NoteRow({required this.note, required this.onTap});
final ReleaseNoteSummary note;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
return Semantics(
container: true,
button: true,
child: InkWell(
onTap: onTap,
child: DecoratedBox(
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: theme.colorScheme.surfaceContainerHigh,
width: AppSize.borderWidth,
),
),
),
child: ConstrainedBox(
constraints: const BoxConstraints(
minHeight: AppSize.settingsRowMinHeight,
),
child: Padding(
padding: const EdgeInsetsDirectional.fromSTEB(
AppSpacing.s20,
AppSpacing.s10,
AppSpacing.s12,
AppSpacing.s10,
),
child: Row(
spacing: AppSpacing.s12,
children: <Widget>[
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: AppSpacing.s2,
children: <Widget>[
KeepAllText(
note.title,
style: theme.textTheme.bodyLarge?.copyWith(
fontWeight: AppTypography.medium,
height: AppTypography.lineHeightHeading,
),
),
ReleaseNoteDateText(
note.date,
style: theme.textTheme.bodySmall?.copyWith(
height: AppTypography.lineHeightHeading,
),
),
],
),
),
ExcludeSemantics(
child: Icon(
Icons.chevron_right,
size: AppSize.iconMedium,
color: extra.textMuted,
),
),
],
),
),
),
),
),
);
}
}
/// [더 보기] — 폭 전체 48 · 회색 테두리(보드 B2, 둘레 위 16 · 옆 20 · 아래 24). 받는 동안은 둥근 진행 표시와 회색 글이고 다시 누르지 못한다.
/// 초점은 지킨다(꺼진 단추로 바꾸지 않고 누름만 받지 않는다 — 메일 검색의 [더 찾기]와 같다). 화면 낭독기는 꺼진 단추 "더 보기, 불러오는 중"으로 읽는다
class _MoreButton extends StatelessWidget {
const _MoreButton({required this.busy, required this.onPressed});
final bool busy;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final ColorScheme scheme = theme.colorScheme;
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
return Padding(
padding: const EdgeInsets.fromLTRB(
AppSpacing.s20,
AppSpacing.s16,
AppSpacing.s20,
AppSpacing.s24,
),
child: Semantics(
container: true,
button: true,
enabled: !busy,
liveRegion: busy,
label: ReleaseNotesTexts.more,
value: busy ? ReleaseNotesTexts.moreBusy : null,
onTap: busy ? null : onPressed,
child: ExcludeSemantics(
child: OutlinedButton(
// 받는 동안도 단추는 그대로 둔다(초점이 옮겨 가지 않게) — 누름만 받지 않는다
onPressed: busy ? _ignore : onPressed,
style: OutlinedButton.styleFrom(
minimumSize: const Size.fromHeight(AppSize.touchMin),
foregroundColor: busy ? extra.iconMuted : extra.textStrong,
backgroundColor: scheme.surface,
overlayColor: busy ? Colors.transparent : null,
side: BorderSide(
color: busy
? scheme.surfaceContainerHighest
: extra.borderStrong,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.lg),
),
textStyle: theme.textTheme.labelLarge,
),
child: Row(
mainAxisSize: MainAxisSize.min,
spacing: AppSpacing.s10,
children: <Widget>[
if (busy)
SizedBox.square(
dimension: AppSize.iconSmall,
child: CircularProgressIndicator(
strokeWidth: AppSize.progressRingStroke,
color: scheme.primary,
backgroundColor: AppColors.progressRingTrack,
strokeCap: StrokeCap.round,
),
),
const Text(ReleaseNotesTexts.more),
],
),
),
),
),
);
}
static void _ignore() {}
}
/// 글이 없을 때 — 문서 그림 원 · 한 줄(보드 B9 — 빈 메일함과 같은 꼴 · 같은 제목 크기)
class _EmptyList extends StatelessWidget {
const _EmptyList();
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
return StateLayout(
// 웹의 문서 그림(file-text — 접힌 모서리와 글 줄)과 같은 꼴
icon: Icons.description_outlined,
iconColor: extra.textMuted,
iconBackground: theme.colorScheme.surfaceContainerHigh,
title: ReleaseNotesTexts.empty,
children: const <Widget>[],
);
}
}
/// 처음 받는 동안 — 줄 자리의 막대(보드 B7: 줄 68 · 제목 막대 13 · 날짜 막대 104 × 10, 끝이 둥글다).
/// 화면 낭독기는 "업데이트 내역 불러오는 중"
class _ListSkeleton extends StatelessWidget {
const _ListSkeleton();
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final ColorScheme scheme = theme.colorScheme;
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
return LoadingSkeleton(
label: ReleaseNotesTexts.listLoading,
// 가로 화면처럼 낮은 화면에서는 넘치는 줄을 자른다(보드의 `overflow: hidden`)
child: SingleChildScrollView(
physics: const NeverScrollableScrollPhysics(),
child: Column(
children: <Widget>[
for (final double width in _skeletonTitleWidths)
DecoratedBox(
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: scheme.surfaceContainerHigh,
width: AppSize.borderWidth,
),
),
),
child: SizedBox(
height: AppSize.settingsRowMinHeight,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.s20,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
spacing: AppSpacing.s10,
children: <Widget>[
FractionallySizedBox(
widthFactor: width,
child: SkeletonBar(
color: scheme.surfaceContainerHighest,
height: AppSize.skeletonTitleBarHeight,
rounded: true,
),
),
SizedBox(
width: AppSize.skeletonDateBarWidth,
child: SkeletonBar(
color: extra.borderSubtle,
rounded: true,
),
),
],
),
),
),
),
],
),
),
);
}
}
@@ -12,6 +12,7 @@ import 'package:arasystem_app/features/auth/data/models/auth_session_info.dart';
import 'package:arasystem_app/features/auth/presentation/providers/login_providers.dart';
import 'package:arasystem_app/features/auth/presentation/providers/session_provider.dart';
import 'package:arasystem_app/features/auth/presentation/providers/session_state.dart';
import 'package:arasystem_app/features/settings/presentation/release_notes_texts.dart';
import 'package:arasystem_app/features/settings/presentation/settings_texts.dart';
import 'package:arasystem_app/features/settings/presentation/widgets/settings_notification_row.dart';
import 'package:arasystem_app/shared/widgets/confirm_dialog.dart';
@@ -24,13 +25,15 @@ import 'package:go_router/go_router.dart';
/// 글자는 테마 크기로 맞췄다(보드 15.5 → 16 · 14 → 15 · 13 → 13 — 앱 디자인 README 의 크기 정리와 같다).
///
/// 한 화면에 절을 모두 둔다: 내 정보(이름 · 주소) · 알림(새 메일 알림) · 다른 모듈이 내놓은 절 ·
/// 앱 정보(앱 버전 · [새 버전 받기] · 오픈소스 라이선스) · 문의(`GET /api/support-contact` — 로그인 화면과 같은 저장소, 화면을 열 때 받아 둔다) · [로그아웃].
/// 앱 정보(앱 버전 · 업데이트 내역 · [새 버전 받기] · 오픈소스 라이선스) · 문의(`GET /api/support-contact` — 로그인 화면과 같은 저장소, 화면을 열 때 받아 둔다) · [로그아웃].
/// 업데이트 내역(보드 `ReleaseNotes` B1)은 앱 버전 다음 줄이고 오픈소스 라이선스 줄과 같은 꼴이다 — 누르면 목록(전체 화면).
/// [새 버전 받기]는 업데이트 화면의 단추처럼 포털의 휴대폰 앱 받기 화면을 바깥 브라우저로 연다(같은 런처 · 같은 실패 알림 — `core/app_download/`).
/// [로그아웃]은 확인 창부터 연다(처음 초점은 [취소] — 로그아웃하면 이 휴대폰의 새 메일 알림도 멈춘다고 알린다).
/// 비밀번호 · 2단계 인증 같은 계정 설정은 웹에서 한다.
class SettingsPage extends ConsumerStatefulWidget {
const SettingsPage({
required this.licensesLocation,
required this.releaseNotesLocation,
this.sections = const <ModuleSettingsSection>[],
this.sectionLocation,
super.key,
@@ -39,6 +42,9 @@ class SettingsPage extends ConsumerStatefulWidget {
/// 오픈소스 라이선스 화면의 경로
final String licensesLocation;
/// 업데이트 내역 목록의 경로
final String releaseNotesLocation;
/// 다른 모듈이 내놓은 설정 절
final List<ModuleSettingsSection> sections;
@@ -125,6 +131,12 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
_VersionRow(version: version),
_LinkRow(
label: ReleaseNotesTexts.title,
onTap: () => unawaited(
context.push<void>(widget.releaseNotesLocation),
),
),
_LinkRow(
label: AppDownloadTexts.getNewVersion,
opensOutside: true,
@@ -0,0 +1,97 @@
import 'dart:async';
import 'package:arasystem_app/core/api/api_error_mapper.dart';
import 'package:arasystem_app/core/api/api_exception.dart';
import 'package:arasystem_app/features/settings/data/models/release_note.dart';
import 'package:arasystem_app/features/settings/data/repositories/release_notes_repository.dart';
import 'package:arasystem_app/features/settings/presentation/providers/release_notes_providers.dart';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod/misc.dart';
/// 글 화면의 상태
enum ReleaseNoteStatus {
/// 받는 중 — 글 자리를 막대로 잡아 둔다
loading,
/// 받았다
ready,
/// 받지 못했다 — 없는 글(`RES_001`)은 [목록으로], 그 밖은 [다시 시도]
failed,
}
/// 업데이트 내역 글 하나의 상태
@immutable
class ReleaseNoteState {
const ReleaseNoteState({
this.status = ReleaseNoteStatus.loading,
this.note,
this.error,
});
final ReleaseNoteStatus status;
/// 받은 글 — [ReleaseNoteStatus.ready] 일 때만
final ReleaseNote? note;
/// 받지 못한 까닭 (코드 · 추적 ID) — [ReleaseNoteStatus.failed] 일 때만
final ApiException? error;
}
/// 업데이트 내역 글 하나 — 글 id 마다 하나. 글 화면이 있는 동안만 둔다(autoDispose — 다른 글로 옮기면 앞 응답은 버린다)
final NotifierProviderFamily<ReleaseNoteController, ReleaseNoteState, String>
releaseNoteProvider = NotifierProvider.autoDispose
.family<ReleaseNoteController, ReleaseNoteState, String>(
ReleaseNoteController.new,
);
/// 업데이트 내역 글 — 계약 7절 `GET /api/release-notes/{id}` (보드 `ReleaseNotes` B3 · B4 · B5 · B6).
///
/// 알림 없이 받는다 — 없는 글(지워졌거나 주소가 틀림 — 404 `RES_001`)과 그 밖의 실패는 화면이 그 자리에 그린다.
/// 세션이 바뀌면 처음부터 다시 받고, 그 전에 보낸 요청의 응답은 버린다(세대 [_seq] · 시작할 때의 [Ref]).
class ReleaseNoteController extends Notifier<ReleaseNoteState> {
ReleaseNoteController(this.noteId);
/// 주소의 글 id
final String noteId;
int _seq = 0;
ReleaseNotesRepository get _repository =>
ref.read(releaseNotesRepositoryProvider);
@override
ReleaseNoteState build() {
final String? account = ref.watch(releaseNotesAccountProvider);
_seq++;
// 세션이 없으면(로그아웃하며 화면이 사라지는 중) 묻지 않는다
if (account == null) return const ReleaseNoteState();
unawaited(_load(_seq));
return const ReleaseNoteState();
}
/// [다시 시도] — 처음부터 다시 받는다(그동안 막대)
Future<void> retry() async {
if (!ref.mounted || ref.read(releaseNotesAccountProvider) == null) return;
_seq++;
state = const ReleaseNoteState();
await _load(_seq);
}
Future<void> _load(int seq) async {
final Ref ref = this.ref;
try {
final ReleaseNote note = await _repository.getNote(noteId);
if (!ref.mounted || seq != _seq) return;
state = ReleaseNoteState(status: ReleaseNoteStatus.ready, note: note);
} on DioException catch (error) {
if (!ref.mounted || seq != _seq) return;
state = ReleaseNoteState(
status: ReleaseNoteStatus.failed,
error: toApiException(error),
);
}
}
}
@@ -0,0 +1,163 @@
import 'dart:async';
import 'package:arasystem_app/core/api/api_error_mapper.dart';
import 'package:arasystem_app/core/api/api_exception.dart';
import 'package:arasystem_app/core/api/dio_provider.dart';
import 'package:arasystem_app/features/settings/data/models/release_note.dart';
import 'package:arasystem_app/features/settings/data/repositories/release_notes_repository.dart';
import 'package:arasystem_app/features/settings/presentation/providers/release_notes_providers.dart';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// 목록의 상태
enum ReleaseNotesListStatus {
/// 첫 쪽을 받는 중 — 줄 자리를 막대로 잡아 둔다
loading,
/// 받았다(글이 없을 수도 있다)
ready,
/// 첫 쪽을 받지 못했다 — 받지 못함 + [다시 시도]
failed,
}
/// 업데이트 내역 목록 — 받은 줄 · 다음 쪽 · [더 보기]를 받는 중
@immutable
class ReleaseNotesListState {
const ReleaseNotesListState({
this.status = ReleaseNotesListStatus.loading,
this.items = const <ReleaseNoteSummary>[],
this.hasNext = false,
this.nextCursor,
this.error,
this.loadingMore = false,
});
final ReleaseNotesListStatus status;
/// 받은 줄 — 최근 글부터(서버 차례 그대로)
final List<ReleaseNoteSummary> items;
final bool hasNext;
/// 다음 쪽 커서 — 해석하지 않고 그대로 보낸다
final String? nextCursor;
/// 첫 쪽을 받지 못한 까닭 (코드 · 추적 ID)
final ApiException? error;
/// [더 보기]로 다음 쪽을 받는 중 — 그 단추만 바쁨 표시
final bool loadingMore;
/// 다음 쪽이 있다 — [더 보기]를 보인다
bool get hasMore => hasNext && (nextCursor?.isNotEmpty ?? false);
ReleaseNotesListState copyWith({bool? loadingMore}) => ReleaseNotesListState(
status: status,
items: items,
hasNext: hasNext,
nextCursor: nextCursor,
error: error,
loadingMore: loadingMore ?? this.loadingMore,
);
}
/// 업데이트 내역 목록 — 목록 화면이 있는 동안만 둔다(autoDispose — 다시 열면 새로 받는다)
final NotifierProvider<ReleaseNotesListController, ReleaseNotesListState>
releaseNotesListProvider =
NotifierProvider.autoDispose<
ReleaseNotesListController,
ReleaseNotesListState
>(ReleaseNotesListController.new);
/// 업데이트 내역 목록 — 계약 7절 `GET /api/release-notes` (보드 `ReleaseNotes` B2 · B7 · B8 · B9).
///
/// - 첫 쪽 · [다시 시도]는 알림 없이 받는다 — 받지 못하면 화면이 그 자리에 사전 문구 · 문의 코드 · [다시 시도]를 그린다.
/// - [더 보기]는 다음 쪽을 받아 뒤에 붙인다(이미 있는 글은 건너뛴다 — 그사이 글이 더해져 자리가 밀렸을 수 있다).
/// 받지 못하면 인터셉터가 알리고 받은 줄은 그대로 둔다(저절로 다시 하지 않는다 — [더 보기]를 다시 누른다).
/// - 늦은 응답: 처음부터 다시 받을 때마다(다시 시도 · 세션이 바뀜) 세대([_seq])가 오른다 — 그 전에 보낸 요청의 응답은 버린다.
/// 비동기 일은 시작할 때의 [Ref] 를 쥐고, 끝난 뒤 그 Ref 가 살아 있을 때만 상태를 바꾼다(다시 만들어졌거나 사라졌으면 버린다).
class ReleaseNotesListController extends Notifier<ReleaseNotesListState> {
int _seq = 0;
ReleaseNotesRepository get _repository =>
ref.read(releaseNotesRepositoryProvider);
@override
ReleaseNotesListState build() {
final String? account = ref.watch(releaseNotesAccountProvider);
_seq++;
// 세션이 없으면(로그아웃하며 화면이 사라지는 중) 묻지 않는다 — 세션 없는 요청이 갱신을 부르지 않게
if (account == null) return const ReleaseNotesListState();
unawaited(_loadFirst(_seq));
return const ReleaseNotesListState();
}
/// [다시 시도] — 첫 쪽을 처음부터 다시 받는다(그동안 막대)
Future<void> retry() async {
if (!ref.mounted || ref.read(releaseNotesAccountProvider) == null) return;
_seq++;
state = const ReleaseNotesListState();
await _loadFirst(_seq);
}
Future<void> _loadFirst(int seq) async {
final Ref ref = this.ref;
try {
final ReleaseNotesPage page = await _repository.listNotes(silent: true);
if (!ref.mounted || seq != _seq) return;
state = ReleaseNotesListState(
status: ReleaseNotesListStatus.ready,
items: page.items,
hasNext: page.hasNext,
nextCursor: page.nextCursor,
);
} on DioException catch (error) {
if (!ref.mounted || seq != _seq) return;
state = ReleaseNotesListState(
status: ReleaseNotesListStatus.failed,
error: toApiException(error),
);
}
}
/// [더 보기] — 다음 쪽을 받아 뒤에 붙인다. 받는 중 · 다음 쪽 없음이면 아무 일 없다
Future<void> loadMore() async {
final Ref ref = this.ref;
if (!ref.mounted) return;
final ReleaseNotesListState current = state;
final String? cursor = current.nextCursor;
if (current.status != ReleaseNotesListStatus.ready ||
!current.hasMore ||
current.loadingMore ||
cursor == null) {
return;
}
final int seq = _seq;
state = current.copyWith(loadingMore: true);
try {
final ReleaseNotesPage page = await _repository.listNotes(cursor: cursor);
if (!ref.mounted || seq != _seq) return;
final Set<String> known = <String>{
for (final ReleaseNoteSummary note in state.items) note.id,
};
state = ReleaseNotesListState(
status: ReleaseNotesListStatus.ready,
items: List<ReleaseNoteSummary>.unmodifiable(<ReleaseNoteSummary>[
...state.items,
for (final ReleaseNoteSummary note in page.items)
if (!known.contains(note.id)) note,
]),
hasNext: page.hasNext,
nextCursor: page.nextCursor,
);
} on DioException catch (error) {
if (!ref.mounted || seq != _seq) return;
// 그 밖의 실패는 인터셉터가 이미 알렸다 — 모양이 틀린 응답만 아무도 알리지 않았다
if (error is ReleaseNotesMalformedResponse) {
ref.read(errorNotifierProvider).show(toApiException(error));
}
state = state.copyWith(loadingMore: false);
}
}
}
@@ -0,0 +1,25 @@
import 'package:arasystem_app/core/api/dio_provider.dart';
import 'package:arasystem_app/features/auth/presentation/providers/session_provider.dart';
import 'package:arasystem_app/features/auth/presentation/providers/session_state.dart';
import 'package:arasystem_app/features/settings/data/repositories/release_notes_repository.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// 업데이트 내역 저장소 — 인터셉터(언래핑 · Bearer · 갱신 · 알림)가 걸린 `dioProvider` 의 Dio 를 쓴다
final Provider<ReleaseNotesRepository> releaseNotesRepositoryProvider =
Provider<ReleaseNotesRepository>(
(Ref ref) => ReleaseNotesRepository(ref.watch(dioProvider)),
);
/// 업데이트 내역을 보는 직원 — 로그인했고 제한이 없을 때 그 주소, 그 밖에는 null.
///
/// 목록 · 글은 이것을 듣고 바뀌면 처음부터 다시 받는다 — 로그아웃 · 세션 끝 · 다른 직원 로그인 뒤에 앞 세션의 늦은 응답을 쓰지 않게.
/// 로그아웃은 늘 null 을 거치므로 같은 직원이 다시 들어와도 새로 시작한다(메일의 `mailAccountProvider` 와 같은 규칙 —
/// 모듈끼리 import 하지 않아 따로 둔다)
final Provider<String?> releaseNotesAccountProvider = Provider<String?>((
Ref ref,
) {
final SessionState? session = ref.watch(sessionProvider).value;
return session is SignedIn && session.info.restriction == null
? session.info.email
: null;
});
@@ -0,0 +1,44 @@
/// 업데이트 내역의 화면 글 — 원본은 보드 `ReleaseNotes`(`docs/design/webmail/ReleaseNotes.dc.html`)의 B(앱)다.
///
/// 글은 보드 그대로이고, 화면 낭독기에만 읽히는 글 가운데 보드에 없는 것은 앱에서 새로 썼다(그 줄에 적었다).
/// 웹(`frontend/src/shared/constants/release-notes.ts` 의 `RELEASE_NOTES_TEXT`)과 같은 글이다 — 웹과 앱을 함께 쓰는 직원에게 같은 말.
/// 받지 못함 · 없는 글의 문구는 여기 두지 않는다 — 에러 코드 사전에서만 온다. 글 내용(제목 · 묶음 · 줄)은 서버가 주는 글 그대로 그린다.
/// 말투는 디자인 README 「화면 문구 — 사용자에게 말하듯」.
library;
abstract final class ReleaseNotesTexts {
/// 설정 › 앱 정보의 줄 · 목록 화면의 제목 (보드 B1 · B2)
static const String title = '업데이트 내역';
/// 위 막대의 뒤로 — 화면 낭독기 (보드의 "뒤로")
static const String back = '뒤로';
/// 목록 끝 — 글이 한 쪽(20개)보다 많을 때만 (보드 B2)
static const String more = '더 보기';
/// [더 보기]를 누른 뒤 받는 동안 — 화면 낭독기가 단추 이름 다음에 읽는다 (앱에서 새로 쓴 글 — 보드는 바쁨 표시만)
static const String moreBusy = '불러오는 중';
/// 목록을 처음 받는 동안 — 화면 낭독기 (보드 B7)
static const String listLoading = '업데이트 내역 불러오는 중';
/// 글을 받는 동안 — 화면 낭독기 (보드 "상태와 [더 보기]" — 글 자리를 막대로, 웹과 같은 글)
static const String noteLoading = '글 불러오는 중';
/// 글이 하나도 없을 때 (보드 B9)
static const String empty = '아직 알려 드릴 업데이트가 없습니다.';
/// 없는 글의 단추 — 위 막대의 뒤로처럼 목록으로 (보드 B5)
static const String toList = '목록으로';
/// 글 끝의 다른 글 줄 — 바로 더 새 글(서버의 next — 위 줄) · 바로 더 옛 글(previous — 아래 줄) (보드 B3 · B4)
static const String newer = '다음 글';
static const String older = '이전 글';
/// 다른 글 줄의 작은 글 — "다음 글 · 2026년 11월 18일"
static String neighborLine(String label, String date) => '$label · $date';
/// 그 줄을 화면 낭독기로 — 가운뎃점 대신 쉼표로 쉰다 (앱에서 새로 쓴 글)
static String neighborLineSpoken(String label, String date) =>
'$label, $date';
}
@@ -1,6 +1,7 @@
/// 설정 모듈의 화면 글 — 앱 보드 `App1Shell`(설정 탭 · 로그아웃 확인 창)의 글 그대로다.
/// 새 메일 알림 줄의 글은 받은편지함 권하기 줄과 함께 쓰므로 `core/push/push_texts.dart` 에 있다(보드 `App1Push`).
/// 앱 정보의 [새 버전 받기] 줄과 그 실패 알림은 업데이트 화면과 함께 쓰므로 `core/app_download/app_download_texts.dart` 에 있다(보드 `App1Push` ④).
/// 앱 정보의 업데이트 내역 줄 · 그 화면들의 글은 `release_notes_texts.dart` 에 있다(보드 `ReleaseNotes` B).
///
/// 화면 글은 디자인 README 「화면 문구 — 사용자에게 말하듯」 말투를 따른다.
abstract final class SettingsTexts {
@@ -0,0 +1,105 @@
/// 업데이트 내역 목록 · 글 화면이 함께 쓰는 부품 — 위 막대 · 가운데 상태 자리 · 날짜 글 (보드 `ReleaseNotes` B)
library;
import 'package:arasystem_app/core/theme/app_spacing.dart';
import 'package:arasystem_app/core/theme/app_theme.dart';
import 'package:arasystem_app/core/theme/app_typography.dart';
import 'package:arasystem_app/core/utils/calendar_date.dart';
import 'package:arasystem_app/core/utils/korean_format.dart';
import 'package:arasystem_app/features/settings/presentation/release_notes_texts.dart';
import 'package:flutter/material.dart';
/// 위 막대 — 뒤로 · (목록이면) 제목 "업데이트 내역" (보드 B2 · B3, 높이 64 · 아래 선). 글 화면은 뒤로만 둔다
class ReleaseNotesAppBar extends StatelessWidget
implements PreferredSizeWidget {
const ReleaseNotesAppBar({required this.onBack, this.title, super.key});
final VoidCallback onBack;
/// 제목 — 글 화면은 없다
final String? title;
@override
Size get preferredSize => const Size.fromHeight(AppSize.appBarHeight);
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
final String? text = title;
return AppBar(
toolbarHeight: AppSize.appBarHeight,
automaticallyImplyLeading: false,
// 뒤로(48)의 양옆 4 · 제목은 그 뒤 4 — 보드의 `padding: 0 4px` · `gap: 4px` · 제목 `padding-left: 4px`
leadingWidth: AppSize.touchMin + AppSpacing.s8,
titleSpacing: AppSpacing.s4,
shape: Border(
bottom: BorderSide(
color: extra.borderSubtle,
width: AppSize.borderWidth,
),
),
leading: IconButton(
icon: const Icon(Icons.arrow_back, size: AppSize.iconLarge),
tooltip: ReleaseNotesTexts.back,
color: extra.textStrong,
onPressed: onBack,
),
title: text == null
? null
: Semantics(
header: true,
child: Text(
text,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: AppTypography.bold,
),
),
),
);
}
}
/// 가운데 상태(글 없음 · 받지 못함 · 없는 글)의 자리 — 남은 높이를 채우고 가운데에 둔다(큰 글자로 넘치면 스크롤)
class ReleaseNotesStateFill extends StatelessWidget {
const ReleaseNotesStateFill({required this.child, super.key});
final Widget child;
@override
Widget build(BuildContext context) {
return CustomScrollView(
slivers: <Widget>[
SliverFillRemaining(hasScrollBody: false, child: Center(child: child)),
],
);
}
}
/// 받지 못함 · 없는 글의 제목 글자 — 긴 사전 문장이라 15(보드 B5 · B6 · B8 — 메일 검색의 상태와 같다)
TextStyle? releaseNotesErrorTitleStyle(ThemeData theme) =>
theme.textTheme.titleSmall?.copyWith(fontWeight: AppTypography.semiBold);
/// 쓴 날 — "2026년 11월 18일"(달력 날짜 그대로 — 시간대를 바꾸지 않는다), 숫자는 고른 폭
class ReleaseNoteDateText extends StatelessWidget {
const ReleaseNoteDateText(this.date, {this.style, super.key});
final CalendarDate date;
/// 글자 — 색 · 숫자 폭은 이 위젯이 입힌다
final TextStyle? style;
@override
Widget build(BuildContext context) {
final AppExtraColors extra = Theme.of(context).extension<AppExtraColors>()!;
return Text(
koreanCalendarDate(date),
style: style?.copyWith(
color: extra.textMuted,
fontFeatures: const <FontFeature>[FontFeature.tabularFigures()],
),
);
}
}
@@ -2,6 +2,8 @@ import 'package:arasystem_app/core/constants/app_constants.dart';
import 'package:arasystem_app/core/constants/app_info_provider.dart';
import 'package:arasystem_app/core/modules/app_modules.dart';
import 'package:arasystem_app/core/modules/module_routes.dart';
import 'package:arasystem_app/features/settings/presentation/pages/release_note_page.dart';
import 'package:arasystem_app/features/settings/presentation/pages/release_notes_page.dart';
import 'package:arasystem_app/features/settings/presentation/pages/settings_page.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -15,6 +17,13 @@ abstract final class SettingsRoutes {
/// 오픈소스 라이선스 (전체 화면)
static String get licenses => '$home/licenses';
/// 업데이트 내역 목록 (전체 화면 — 보드 `ReleaseNotes` B)
static String get releaseNotes => '$home/release-notes';
/// 업데이트 내역 글 하나 (전체 화면 — 목록 위에 쌓인다). id 는 경로 칸으로 인코딩한다
static String releaseNote(String noteId) =>
'$releaseNotes/${Uri.encodeComponent(noteId)}';
/// 다른 모듈이 내놓은 설정 절 (`/settings/<path>`)
static String section(String path) => '$home/$path';
}
@@ -22,7 +31,8 @@ abstract final class SettingsRoutes {
/// 설정 모듈의 라우트 묶음 — core/router/module_registry.dart 가 모은다.
///
/// 설정은 한 화면이다(앱 보드 `App1Shell` · `App1Push` ④ — `presentation/pages/settings_page.dart`).
/// 그 아래에는 오픈소스 라이선스(전체 화면)와 다른 모듈이 내놓은 절만 있다.
/// 그 아래에는 오픈소스 라이선스 · 업데이트 내역(목록과 그 위에 쌓이는 글 — 보드 `ReleaseNotes` B, 모두 전체 화면)과
/// 다른 모듈이 내놓은 절만 있다.
final ModuleRoutes settingsModuleRoutes = ModuleRoutes(
id: AppModuleId.settings,
buildRoutes: (ModuleRouteContext context) => <RouteBase>[
@@ -30,6 +40,7 @@ final ModuleRoutes settingsModuleRoutes = ModuleRoutes(
path: SettingsRoutes.home,
builder: (BuildContext _, GoRouterState _) => SettingsPage(
licensesLocation: SettingsRoutes.licenses,
releaseNotesLocation: SettingsRoutes.releaseNotes,
sections: context.settingsSections,
sectionLocation: SettingsRoutes.section,
),
@@ -39,6 +50,30 @@ final ModuleRoutes settingsModuleRoutes = ModuleRoutes(
parentNavigatorKey: context.rootNavigatorKey,
builder: (BuildContext _, GoRouterState _) => const _LicensesPage(),
),
GoRoute(
path: 'release-notes',
parentNavigatorKey: context.rootNavigatorKey,
builder: (BuildContext _, GoRouterState _) => ReleaseNotesPage(
noteLocation: SettingsRoutes.releaseNote,
exitLocation: SettingsRoutes.home,
),
routes: <RouteBase>[
GoRoute(
path: ':noteId',
parentNavigatorKey: context.rootNavigatorKey,
builder: (BuildContext _, GoRouterState state) {
final String noteId = state.pathParameters['noteId']!;
return ReleaseNotePage(
// 글마다 새 화면 — 다른 글로 바꿔 끼우면 앞 글의 상태(받기 · 스크롤)를 이어 쓰지 않는다
key: ValueKey<String>(noteId),
noteId: noteId,
noteLocation: SettingsRoutes.releaseNote,
listLocation: SettingsRoutes.releaseNotes,
);
},
),
],
),
for (final ModuleSettingsSection section in context.settingsSections)
GoRoute(path: section.path, builder: section.builder),
],
@@ -0,0 +1,11 @@
/// 불러오기 상태(받지 못함)의 화면 글 — 모듈이 함께 쓰는 상태 위젯(`load_states.dart`)의 글이다.
///
/// 메일 목록의 받지 못함(웹 `MailLoadError` 와 같은 글)에서 옮겨 왔다 — 메일 글(`MailListTexts.retry` · `traceCode`)은 이것을 가리킨다.
/// 받지 못한 까닭의 문구는 여기 두지 않는다 — 에러 코드 사전(`ErrorLexicon`)에서만 온다.
abstract final class LoadStateTexts {
/// 받지 못함의 [다시 시도]
static const String retry = '다시 시도';
/// 문의 코드 — 추적 ID 앞자리("문의 코드 3f1c0a2e")
static String traceCode(String code) => '문의 코드 $code';
}
+208
View File
@@ -0,0 +1,208 @@
/// 불러오기 상태의 틀 — 가운데 상태(빈 목록 · 받지 못함 등) · 받지 못함 · 불러오는 중 뼈대(막대).
///
/// 메일 목록(`features/mail/presentation/widgets/mail_list_states.dart`)에 있던 것을 모양 · 동작 그대로 옮겨 왔다 —
/// 모듈끼리는 import 하지 않으므로 함께 쓰는 위젯은 여기에 둔다(메일 목록 · 검색 · 읽기 · 쓰기, 설정의 업데이트 내역이 쓴다).
/// 받지 못한 까닭의 문구는 에러 코드 사전(`ApiException.message`)에서만 온다.
library;
import 'package:arasystem_app/core/api/api_exception.dart';
import 'package:arasystem_app/core/theme/app_spacing.dart';
import 'package:arasystem_app/core/theme/app_theme.dart';
import 'package:arasystem_app/core/theme/app_typography.dart';
import 'package:arasystem_app/shared/widgets/keep_all_text.dart';
import 'package:arasystem_app/shared/widgets/load_state_texts.dart';
import 'package:flutter/material.dart';
/// 문의 코드로 보이는 추적 ID 앞자리 수 — 웹 `TRACE_CODE_LENGTH` 와 같다
const int kTraceCodeLength = 8;
/// 가운데 상태의 아래 여백 — 안내가 화면 가운데보다 조금 위에 오게(보드의 `padding-bottom: 120px`)
const double _stateBottomSpace = 120;
/// 가운데 상태 화면의 틀 — 아이콘 원(72) · 제목(머리) · 그 아래 글 · 단추. 화면 가운데보다 조금 위에 온다.
/// 메일 목록의 빈 메일함 · 받지 못함, 메일 검색의 결과 없음 · 아직 못 찾음 · 찾을 말이 너무 많음, 업데이트 내역의 글 없음 · 받지 못함이 쓴다
class StateLayout extends StatelessWidget {
const StateLayout({
required this.icon,
required this.iconColor,
required this.iconBackground,
required this.title,
required this.children,
this.titleStyle,
super.key,
});
final IconData icon;
final Color iconColor;
final Color iconBackground;
final String title;
final List<Widget> children;
/// 제목 글자 — 없으면 `titleMedium`(목록 보드 17 → 18). 검색 보드 · 받지 못함의 긴 문장은 15(`titleSmall`)
final TextStyle? titleStyle;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.fromLTRB(
AppSpacing.s40,
0,
AppSpacing.s40,
_stateBottomSpace,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
ExcludeSemantics(
child: Container(
width: AppSize.stateIcon,
height: AppSize.stateIcon,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: iconBackground,
),
child: Icon(icon, size: AppSize.stateIconGlyph, color: iconColor),
),
),
const SizedBox(height: AppSpacing.s16),
// 여러 줄(긴 사전 문구)이어도 가운데에 — 글 자체를 가운데 맞춘다
DefaultTextStyle.merge(
textAlign: TextAlign.center,
child: Semantics(
header: true,
child: KeepAllText(
title,
style:
titleStyle ??
theme.textTheme.titleMedium?.copyWith(
fontWeight: AppTypography.semiBold,
),
),
),
),
const SizedBox(height: AppSpacing.s8),
for (final Widget child in children)
DefaultTextStyle.merge(textAlign: TextAlign.center, child: child),
],
),
);
}
}
/// 받지 못함 — 사전 문구 · 문의 코드 · [다시 시도]. 없는 것(`RES_001`)이고 [onLeave] 가 있으면 [다시 시도] 대신 [leaveLabel] 단추
/// (다시 해도 같으니 빠져나간다 — 메일 목록은 [받은편지함으로], 읽기 화면 · 업데이트 내역의 글은 [목록으로], 쓰기 화면은 [닫기]).
/// 웹 `MailLoadError` 와 같은 결 — 문구는 에러 코드 사전에서만 온다. 제목은 [titleStyle](없으면 [StateLayout] 의 기본)
class LoadErrorView extends StatelessWidget {
const LoadErrorView({
required this.error,
required this.onRetry,
this.onLeave,
this.leaveLabel,
this.titleStyle,
super.key,
}) : assert(
onLeave == null || leaveLabel != null,
'onLeave 를 주면 leaveLabel 도 준다',
);
final ApiException error;
final VoidCallback onRetry;
/// 없는 것(`RES_001`)일 때 갈 곳 — 없으면 늘 [다시 시도]
final VoidCallback? onLeave;
/// [onLeave] 단추의 이름 — [onLeave] 와 함께 준다
final String? leaveLabel;
/// 제목 글자 — 없으면 [StateLayout] 의 기본(목록 보드의 크기)
final TextStyle? titleStyle;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
final VoidCallback? leave = onLeave;
// 없는 것이면 다시 해도 같다 — 빠져나갈 단추의 이름(그 밖에는 null — [다시 시도])
final String? leaveText = error.code == 'RES_001' && leave != null
? leaveLabel
: null;
final String? trace = traceCodeOf(error.traceId);
return StateLayout(
icon: Icons.error_outline,
iconColor: theme.colorScheme.error,
iconBackground: theme.colorScheme.errorContainer,
title: error.message,
titleStyle: titleStyle,
children: <Widget>[
if (trace != null)
Text(
LoadStateTexts.traceCode(trace),
style: theme.textTheme.bodySmall?.copyWith(color: extra.textMuted),
),
TextButton(
onPressed: leaveText != null ? leave : onRetry,
child: Text(leaveText ?? LoadStateTexts.retry),
),
],
);
}
}
/// 추적 ID → 화면의 문의 코드(앞 8자리). 없으면 null
String? traceCodeOf(String? traceId) {
final String? value = traceId?.trim();
if (value == null || value.isEmpty) return null;
return value.length > kTraceCodeLength
? value.substring(0, kTraceCodeLength)
: value;
}
/// 처음 불러오는 동안의 뼈대 틀 — 화면 낭독기는 [label] 한 줄로 읽고(바뀌면 알린다) 안의 막대는 읽지 않는다.
/// 막대의 모양(줄 꼴)은 화면마다 [child] 로 그린다 — 메일 목록 · 검색(`MailListSkeleton`), 업데이트 내역의 목록 · 글
class LoadingSkeleton extends StatelessWidget {
const LoadingSkeleton({required this.label, required this.child, super.key});
/// 화면 낭독기 글 — "메일 목록 불러오는 중" · "업데이트 내역 불러오는 중"
final String label;
final Widget child;
@override
Widget build(BuildContext context) {
return Semantics(
label: label,
liveRegion: true,
child: ExcludeSemantics(child: child),
);
}
}
/// 뼈대의 회색 막대 하나 — 폭은 부르는 쪽이(`FractionallySizedBox` · `SizedBox`) 정한다.
/// 메일 목록 보드는 높이 10 · 모서리 4, 업데이트 내역 보드는 끝이 둥근 막대([rounded])다
class SkeletonBar extends StatelessWidget {
const SkeletonBar({
required this.color,
this.height = AppSpacing.s10,
this.rounded = false,
super.key,
});
final Color color;
final double height;
/// 끝을 둥글게(알약) — 아니면 모서리 4
final bool rounded;
@override
Widget build(BuildContext context) {
return Container(
height: height,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(
rounded ? height / 2 : AppRadius.xs,
),
),
);
}
}
+11 -8
View File
@@ -226,12 +226,22 @@ void main() {
await tester.pumpAndSettle();
expect(find.widgetWithText(AppBar, SettingsTexts.home), findsOneWidget);
final Finder settingsScroll = find.descendant(
of: find.byType(SettingsPage),
matching: find.byType(Scrollable),
);
// 아래쪽 절(문의)은 화면 밖이면 아직 그리지 않았으니 끌어 올려 찾는다(앱 정보에 업데이트 내역 줄이 늘었다)
for (final String item in <String>[
SettingsTexts.account,
SettingsTexts.notifications,
SettingsTexts.about,
SettingsTexts.support,
]) {
await tester.scrollUntilVisible(
page(item),
200,
scrollable: settingsScroll,
);
expect(page(item), findsOneWidget, reason: item);
}
@@ -240,14 +250,7 @@ void main() {
OutlinedButton,
SettingsTexts.signOut,
);
await tester.scrollUntilVisible(
signOut,
200,
scrollable: find.descendant(
of: find.byType(SettingsPage),
matching: find.byType(Scrollable),
),
);
await tester.scrollUntilVisible(signOut, 200, scrollable: settingsScroll);
await tester.pumpAndSettle();
await tester.tap(signOut);
await tester.pumpAndSettle();
@@ -0,0 +1,64 @@
import 'package:arasystem_app/core/utils/calendar_date.dart';
import 'package:arasystem_app/core/utils/korean_format.dart';
import 'package:flutter_test/flutter_test.dart';
/// 달력 날짜 — 시각이 아닌 "그날"(업데이트 내역 글의 쓴 날 — 계약 7절). 시간대를 바꾸지 않는다
void main() {
group('CalendarDate.tryParse', () {
test('`YYYY-MM-DD` 를 연 · 월 · 일로 읽는다', () {
expect(
CalendarDate.tryParse('2026-11-18'),
const CalendarDate(2026, 11, 18),
);
expect(
CalendarDate.tryParse('2027-01-05'),
const CalendarDate(2027, 1, 5),
);
expect(
CalendarDate.tryParse('2028-02-29'),
const CalendarDate(2028, 2, 29),
reason: '윤년',
);
});
test('없는 날 · 모양이 다른 글은 null', () {
for (final Object? value in <Object?>[
'2026-02-29',
'2026-02-30',
'2026-04-31',
'2026-13-01',
'2026-00-10',
'2026-11-00',
'2026-1-5',
'2026-11-18-2',
'2026-11-18T00:00:00Z',
' 2026-11-18',
'2026-11-18',
'',
null,
20261118,
]) {
expect(CalendarDate.tryParse(value), isNull, reason: '$value');
}
});
test('글로 되돌리면 서버가 준 모양 그대로', () {
expect(CalendarDate.tryParse('2026-01-05').toString(), '2026-01-05');
});
});
group('koreanCalendarDate — 웹 { year · month: long · day } 와 같은 글', () {
test('"2026년 11월 18일" — 앞자리 0 없이', () {
expect(
koreanCalendarDate(const CalendarDate(2026, 11, 18)),
'2026년 11월 18일',
);
expect(koreanCalendarDate(const CalendarDate(2027, 1, 5)), '2027년 1월 5일');
});
test('시간대와 상관없이 그날 — 시각으로 바꾸지 않는다(UTC 자정이면 음수 시간대에서 하루 앞이 된다)', () {
final CalendarDate date = CalendarDate.tryParse('2026-11-01')!;
expect(koreanCalendarDate(date), '2026년 11월 1일');
});
});
}
@@ -0,0 +1,477 @@
import 'dart:async';
import 'package:arasystem_app/core/api/error_lexicon.dart';
import 'package:arasystem_app/features/settings/data/repositories/release_notes_repository.dart';
import 'package:arasystem_app/features/settings/presentation/pages/release_note_page.dart';
import 'package:arasystem_app/features/settings/presentation/pages/release_notes_page.dart';
import 'package:arasystem_app/features/settings/presentation/pages/settings_page.dart';
import 'package:arasystem_app/features/settings/presentation/providers/release_notes_list_controller.dart';
import 'package:arasystem_app/features/settings/presentation/release_notes_texts.dart';
import 'package:arasystem_app/features/settings/settings_routes.dart';
import 'package:arasystem_app/shared/widgets/load_state_texts.dart';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import '../../support/fakes.dart';
import '../../support/login_fakes.dart';
import '../../support/mail_harness.dart';
import '../../support/push_screen_harness.dart';
import '../../support/release_notes_fakes.dart';
/// 업데이트 내역 — 보드 `ReleaseNotes` B(목록 · 글 · 상태). 로그인한 앱 전체를 가짜 서버로 띄워 본다
void main() {
late MailAppHarness h;
late FakeReleaseNotesBackend notes;
setUp(() {
h = MailAppHarness();
notes = FakeReleaseNotesBackend(h.server)..install();
});
/// 앱을 띄워 [location] 까지 그리고 응답이 모두 올 때까지
Future<void> open(WidgetTester tester, String location) async {
await h.pump(tester, location: location);
await settle(tester);
}
Future<void> openList(WidgetTester tester) =>
open(tester, SettingsRoutes.releaseNotes);
ReleaseNotesListState listState() =>
h.container.read(releaseNotesListProvider);
/// 글 화면의 주소 — `push` 는 라우터의 주소를 바꾸지 않는다
String noteLocation(WidgetTester tester) => GoRouterState.of(
tester.element(find.byType(ReleaseNotePage)),
).uri.toString();
Finder moreButton() =>
find.widgetWithText(OutlinedButton, ReleaseNotesTexts.more);
Finder neighbor(String label, String date) =>
find.text(ReleaseNotesTexts.neighborLine(label, date));
Future<void> scrollTo(WidgetTester tester, Finder finder) async {
await tester.scrollUntilVisible(
finder,
500,
scrollable: find.byType(Scrollable).first,
);
await tester.pumpAndSettle();
}
Future<void> tapVisible(WidgetTester tester, Finder finder) async {
await tester.ensureVisible(finder);
await tester.pumpAndSettle();
await tester.tap(finder);
await settle(tester);
}
Future<void> back(WidgetTester tester) async {
await tester.tap(find.byTooltip(ReleaseNotesTexts.back));
await settle(tester);
}
group('목록 — 보드 B2', () {
testWidgets('최근 글부터 줄마다 제목 · 날짜 · ›, 한 쪽(20개)에 다 들면 [더 보기]가 없다', (
WidgetTester tester,
) async {
await openList(tester);
expect(find.byType(ReleaseNotesPage), findsOneWidget);
expect(find.byType(NavigationBar), findsNothing, reason: '탭 막대 없는 전체 화면');
expect(find.text(ReleaseNotesTexts.title), findsOneWidget);
final List<double> tops = <double>[
for (final FakeReleaseNote note in kBoardReleaseNotes)
tester.getTopLeft(findCopy(note.title)).dy,
];
for (int index = 1; index < tops.length; index++) {
expect(tops[index - 1], lessThan(tops[index]), reason: '최근 글부터');
}
for (final String date in <String>[
'2026년 11월 18일',
'2026년 11월 5일',
'2026년 11월 4일',
'2026년 11월 3일',
]) {
expect(find.text(date), findsOneWidget, reason: date);
}
expect(find.byIcon(Icons.chevron_right), findsNWidgets(4));
expect(moreButton(), findsNothing);
final RequestOptions request = notes.listRequests.single;
expect(request.queryParameters['limit'], kReleaseNotesPageSize);
expect(request.queryParameters.containsKey('cursor'), isFalse);
expect(h.notifier.shownCodes, isEmpty);
});
testWidgets('줄은 최소 68 이고 화면 낭독기는 제목 · 날짜를 단추 하나로 읽는다', (
WidgetTester tester,
) async {
await openList(tester);
final Finder row = find.ancestor(
of: findCopy(kBoardReleaseNotes.first.title),
matching: find.byType(InkWell),
);
expect(tester.getSize(row).height, greaterThanOrEqualTo(68));
expect(
tester.getSemantics(row),
isSemantics(
label: '${kBoardReleaseNotes.first.title}\n2026년 11월 18일',
isButton: true,
),
);
});
testWidgets('[더 보기] — 다음 쪽을 뒤에 붙인다. 받는 동안은 그 단추만 바쁨 표시이고 다시 누르지 못한다', (
WidgetTester tester,
) async {
notes.seed(25);
await openList(tester);
expect(listState().items, hasLength(20));
await scrollTo(tester, moreButton());
final Completer<void> gate = Completer<void>();
notes.listGate = gate;
await tester.tap(moreButton());
// 요청이 가짜 서버에 닿을 때까지(시계가 가야 온다) — 도는 표시가 있어 pumpAndSettle 은 쓰지 않는다
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(notes.listRequests, hasLength(2));
expect(
notes.listRequests.last.queryParameters['cursor'],
'${FakeReleaseNotesBackend.cursorPrefix}20',
);
expect(
find.descendant(
of: moreButton(),
matching: find.byType(CircularProgressIndicator),
),
findsOneWidget,
);
expect(
tester.getSemantics(find.bySemanticsLabel(ReleaseNotesTexts.more)),
isSemantics(
label: ReleaseNotesTexts.more,
value: ReleaseNotesTexts.moreBusy,
isButton: true,
hasEnabledState: true,
isEnabled: false,
),
);
// 받는 동안 다시 눌러도 겹쳐 보내지 않는다
await tester.tap(moreButton());
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(notes.listRequests, hasLength(2));
expect(listState().loadingMore, isTrue);
notes.listGate = null;
gate.complete();
await settle(tester);
expect(listState().items, hasLength(25));
await scrollTo(tester, findCopy('업데이트 1'));
expect(moreButton(), findsNothing, reason: '마지막 쪽');
expect(h.notifier.shownCodes, isEmpty);
});
testWidgets('[더 보기]를 받지 못하면 알리고 받은 줄은 그대로 — 다시 누르면 받는다', (
WidgetTester tester,
) async {
notes.seed(25);
await openList(tester);
await scrollTo(tester, moreButton());
notes.nextListResponse = FakeResponse.fail(500, 'SYS_001');
await tester.tap(moreButton());
await settle(tester);
expect(h.notifier.shownCodes, <String>['SYS_001']);
expect(listState().items, hasLength(20));
expect(listState().loadingMore, isFalse);
expect(
find.descendant(
of: moreButton(),
matching: find.byType(CircularProgressIndicator),
),
findsNothing,
);
await tester.tap(moreButton());
await settle(tester);
expect(listState().items, hasLength(25));
});
testWidgets('[더 보기] 응답의 모양이 틀리면 화면이 알린다(SYS_001) — 받은 줄은 그대로', (
WidgetTester tester,
) async {
notes.seed(25);
await openList(tester);
await scrollTo(tester, moreButton());
notes.nextListResponse = FakeResponse.ok(<String, Object?>{
'items': '모양이 틀림',
});
await tester.tap(moreButton());
await settle(tester);
expect(h.notifier.shownCodes, <String>['SYS_001']);
expect(listState().items, hasLength(20));
expect(moreButton(), findsOneWidget);
});
testWidgets('뒤로 — 설정 탭으로 돌아온다', (WidgetTester tester) async {
await openList(tester);
await back(tester);
expect(find.byType(ReleaseNotesPage), findsNothing);
expect(find.byType(SettingsPage), findsOneWidget);
expect(find.byType(NavigationBar), findsOneWidget);
expect(h.location(), SettingsRoutes.home);
});
});
group('목록의 상태 — 보드 B7 · B8 · B9', () {
testWidgets('받는 동안 줄 자리를 막대로 잡아 둔다 — 화면 낭독기는 "업데이트 내역 불러오는 중"', (
WidgetTester tester,
) async {
final Completer<void> gate = Completer<void>();
notes.listGate = gate;
await h.pump(tester, location: SettingsRoutes.releaseNotes);
expect(
find.bySemanticsLabel(ReleaseNotesTexts.listLoading),
findsOneWidget,
);
expect(findCopy(kBoardReleaseNotes.first.title), findsNothing);
notes.listGate = null;
gate.complete();
await settle(tester);
expect(
find.bySemanticsLabel(ReleaseNotesTexts.listLoading),
findsNothing,
);
expect(findCopy(kBoardReleaseNotes.first.title), findsOneWidget);
});
testWidgets(
'받지 못하면 그 자리에 사전 문구 · 문의 코드 · [다시 시도] — 알리지 않고, [다시 시도]면 다시 받는다',
(WidgetTester tester) async {
notes.nextListResponse = FakeResponse.fail(500, 'SYS_001');
await openList(tester);
expect(findCopy(ErrorLexicon.getMessage('SYS_001')), findsOneWidget);
expect(find.text(LoadStateTexts.traceCode('trace-SY')), findsOneWidget);
expect(find.text(LoadStateTexts.retry), findsOneWidget);
expect(h.notifier.shownCodes, isEmpty);
await tester.tap(find.text(LoadStateTexts.retry));
await settle(tester);
expect(notes.listRequests, hasLength(2));
expect(findCopy(kBoardReleaseNotes.first.title), findsOneWidget);
expect(h.notifier.shownCodes, isEmpty);
},
);
testWidgets('목록은 빠져나갈 곳이 없어 없는 경로(404)여도 [다시 시도]다', (
WidgetTester tester,
) async {
notes.nextListResponse = FakeResponse.fail(404, 'RES_001');
await openList(tester);
expect(findCopy(ErrorLexicon.getMessage('RES_001')), findsOneWidget);
expect(find.text(LoadStateTexts.retry), findsOneWidget);
expect(find.text(ReleaseNotesTexts.toList), findsNothing);
});
testWidgets('글이 없으면 한 줄 — "아직 알려 드릴 업데이트가 없습니다."', (
WidgetTester tester,
) async {
notes.notes = const <FakeReleaseNote>[];
await openList(tester);
expect(findCopy(ReleaseNotesTexts.empty), findsOneWidget);
expect(find.byIcon(Icons.description_outlined), findsOneWidget);
expect(moreButton(), findsNothing);
expect(find.text(LoadStateTexts.retry), findsNothing);
});
});
group('글 — 보드 B3 · B4', () {
testWidgets('줄을 누르면 글 — 날짜 · 제목 · 묶음(머리 · 점 목록), 맨 위 글은 이전 글 줄만', (
WidgetTester tester,
) async {
await openList(tester);
await tapVisible(tester, findCopy(kBoardReleaseNotes.first.title));
expect(find.byType(ReleaseNotePage), findsOneWidget);
expect(noteLocation(tester), SettingsRoutes.releaseNote('2026-11-18'));
expect(
find.byType(ReleaseNotesPage, skipOffstage: false),
findsOneWidget,
reason: '글은 목록 위에 하나만 쌓인다',
);
expect(find.byType(NavigationBar), findsNothing);
expect(notes.noteRequests, <String>['2026-11-18']);
expect(find.text('2026년 11월 18일'), findsOneWidget);
expect(findCopy(kBoardReleaseNotes.first.title), findsOneWidget);
expect(findCopy('휴대폰 앱'), findsOneWidget);
expect(findCopy('메일 검색'), findsOneWidget);
expect(
findCopy('안드로이드 휴대폰 앱이 나왔습니다. 웹메일의 [설정] → [휴대폰 앱]에서 받을 수 있습니다.'),
findsOneWidget,
);
expect(findCopy('찾은 말에는 노란 표시가 붙습니다.'), findsOneWidget);
expect(
tester.getTopLeft(findCopy('휴대폰 앱')).dy,
lessThan(tester.getTopLeft(findCopy('메일 검색')).dy),
);
await tester.ensureVisible(
neighbor(ReleaseNotesTexts.older, '2026년 11월 5일'),
);
await tester.pumpAndSettle();
expect(neighbor(ReleaseNotesTexts.older, '2026년 11월 5일'), findsOneWidget);
expect(find.textContaining(ReleaseNotesTexts.newer), findsNothing);
expect(h.notifier.shownCodes, isEmpty);
});
testWidgets('가운데 글 — 다음 글(더 새 글)이 위 · 이전 글(더 옛 글)이 아래, 머리 없는 묶음은 줄만', (
WidgetTester tester,
) async {
await open(tester, SettingsRoutes.releaseNote('2026-11-05'));
expect(findCopy('화면의 안내 글을 다듬었습니다'), findsOneWidget);
expect(findCopy('단추와 안내 글을 더 알기 쉬운 말로 고쳤습니다.'), findsOneWidget);
final Finder newer = neighbor(ReleaseNotesTexts.newer, '2026년 11월 18일');
final Finder older = neighbor(ReleaseNotesTexts.older, '2026년 11월 4일');
expect(newer, findsOneWidget);
expect(older, findsOneWidget);
expect(
tester.getTopLeft(newer).dy,
lessThan(tester.getTopLeft(older).dy),
);
expect(findCopy(kBoardReleaseNotes.first.title), findsOneWidget);
expect(findCopy('왼쪽 메뉴를 정리했습니다'), findsOneWidget);
final Finder row = find.ancestor(
of: newer,
matching: find.byType(InkWell),
);
expect(tester.getSize(row).height, greaterThanOrEqualTo(64));
expect(
tester.getSemantics(row),
isSemantics(
label:
'${ReleaseNotesTexts.newer}, 2026년 11월 18일\n'
'${kBoardReleaseNotes.first.title}',
isButton: true,
),
);
});
testWidgets('다른 글 줄을 누르면 그 글로 바꿔 끼운다 — 뒤로는 늘 목록', (
WidgetTester tester,
) async {
await openList(tester);
await tapVisible(tester, findCopy(kBoardReleaseNotes.first.title));
await tapVisible(
tester,
neighbor(ReleaseNotesTexts.older, '2026년 11월 5일'),
);
expect(find.byType(ReleaseNotePage, skipOffstage: false), findsOneWidget);
expect(noteLocation(tester), SettingsRoutes.releaseNote('2026-11-05'));
expect(findCopy('단추와 안내 글을 더 알기 쉬운 말로 고쳤습니다.'), findsOneWidget);
expect(notes.noteRequests, <String>['2026-11-18', '2026-11-05']);
await back(tester);
expect(find.byType(ReleaseNotePage), findsNothing);
expect(find.byType(ReleaseNotesPage), findsOneWidget);
expect(findCopy(kBoardReleaseNotes.last.title), findsOneWidget);
});
testWidgets('주소로 바로 연 글도 뒤로는 목록이다', (WidgetTester tester) async {
await open(tester, SettingsRoutes.releaseNote('2026-11-04'));
expect(findCopy('왼쪽 메뉴를 정리했습니다'), findsOneWidget);
await back(tester);
expect(find.byType(ReleaseNotesPage), findsOneWidget);
expect(findCopy(kBoardReleaseNotes.first.title), findsOneWidget);
});
});
group('글의 상태 — 보드 B5 · B6', () {
testWidgets('없는 글(404 RES_001)은 알리지 않고 사전 문구 · 문의 코드 · [목록으로] — 누르면 목록', (
WidgetTester tester,
) async {
await open(tester, SettingsRoutes.releaseNote('2026-01-01'));
expect(findCopy(ErrorLexicon.getMessage('RES_001')), findsOneWidget);
expect(find.text(LoadStateTexts.traceCode('trace-RE')), findsOneWidget);
expect(find.text(ReleaseNotesTexts.toList), findsOneWidget);
expect(find.text(LoadStateTexts.retry), findsNothing);
expect(h.notifier.shownCodes, isEmpty);
await tester.tap(find.text(ReleaseNotesTexts.toList));
await settle(tester);
expect(find.byType(ReleaseNotePage), findsNothing);
expect(find.byType(ReleaseNotesPage), findsOneWidget);
expect(findCopy(kBoardReleaseNotes.first.title), findsOneWidget);
});
testWidgets('그 밖의 실패는 사전 문구 · 문의 코드 · [다시 시도] — 다시 받으면 글', (
WidgetTester tester,
) async {
notes.nextNoteResponse = FakeResponse.fail(500, 'SYS_001');
await open(tester, SettingsRoutes.releaseNote('2026-11-05'));
expect(findCopy(ErrorLexicon.getMessage('SYS_001')), findsOneWidget);
expect(find.text(LoadStateTexts.traceCode('trace-SY')), findsOneWidget);
expect(find.text(ReleaseNotesTexts.toList), findsNothing);
expect(h.notifier.shownCodes, isEmpty);
await tester.tap(find.text(LoadStateTexts.retry));
await settle(tester);
expect(notes.noteRequests, <String>['2026-11-05', '2026-11-05']);
expect(findCopy('화면의 안내 글을 다듬었습니다'), findsOneWidget);
});
testWidgets('받는 동안 글 자리를 막대로 잡아 둔다 — 화면 낭독기는 "글 불러오는 중"', (
WidgetTester tester,
) async {
final Completer<void> gate = Completer<void>();
notes.noteGate = gate;
await h.pump(tester, location: SettingsRoutes.releaseNote('2026-11-05'));
expect(
find.bySemanticsLabel(ReleaseNotesTexts.noteLoading),
findsOneWidget,
);
notes.noteGate = null;
gate.complete();
await settle(tester);
expect(
find.bySemanticsLabel(ReleaseNotesTexts.noteLoading),
findsNothing,
);
expect(findCopy('화면의 안내 글을 다듬었습니다'), findsOneWidget);
});
});
}
@@ -0,0 +1,483 @@
import 'dart:async';
import 'package:arasystem_app/core/api/dio_provider.dart';
import 'package:arasystem_app/core/utils/calendar_date.dart';
import 'package:arasystem_app/core/utils/provider_retry.dart';
import 'package:arasystem_app/features/settings/data/models/release_note.dart';
import 'package:arasystem_app/features/settings/data/repositories/release_notes_repository.dart';
import 'package:arasystem_app/features/settings/presentation/providers/release_note_controller.dart';
import 'package:arasystem_app/features/settings/presentation/providers/release_notes_list_controller.dart';
import 'package:arasystem_app/features/settings/presentation/providers/release_notes_providers.dart';
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod/misc.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../support/fakes.dart';
import '../../support/provider_harness.dart';
import '../../support/release_notes_fakes.dart';
/// 업데이트 내역을 보는 직원 — 시험이 바꾼다(세션이 바뀜)
class _Account extends Notifier<String?> {
@override
String? build() => 'ara.kim@arasystem.it.kr';
void set(String? value) => state = value;
}
final NotifierProvider<_Account, String?> _accountProvider =
NotifierProvider<_Account, String?>(_Account.new);
/// 끝난 요청을 세는 저장소 — 늦은 응답이 컨트롤러에 닿은 뒤를 기다리려고(고정 대기 없이)
class _RecordingRepository extends ReleaseNotesRepository {
_RecordingRepository(super.dio);
int listsFinished = 0;
int notesFinished = 0;
@override
Future<ReleaseNotesPage> listNotes({
String? cursor,
int limit = kReleaseNotesPageSize,
bool silent = false,
}) async {
try {
return await super.listNotes(
cursor: cursor,
limit: limit,
silent: silent,
);
} finally {
listsFinished++;
}
}
@override
Future<ReleaseNote> getNote(String id) async {
try {
return await super.getNote(id);
} finally {
notesFinished++;
}
}
}
/// 업데이트 내역 — 모델 읽기 · 저장소 · 목록 · 글 컨트롤러 (계약 7절)
void main() {
group('모델 읽기', () {
Map<String, Object?> summary(String id, {String? date, String? title}) =>
<String, Object?>{
'id': id,
'date': date ?? id.substring(0, 10),
'title': title ?? '제목 $id',
};
test('목록 한 쪽 — 줄 · 다음 쪽 · 글 수', () {
final ReleaseNotesPage page = ReleaseNotesPage.tryParse(<String, Object?>{
'items': <Object?>[summary('2026-11-18-2'), summary('2026-11-18')],
'pageInfo': <String, Object?>{
'hasNext': true,
'nextCursor': 'abc',
'total': 41,
},
})!;
expect(page.items.map((ReleaseNoteSummary n) => n.id), <String>[
'2026-11-18-2',
'2026-11-18',
]);
expect(page.items.first.date, const CalendarDate(2026, 11, 18));
expect(page.items.first.title, '제목 2026-11-18-2');
expect(page.hasMore, isTrue);
expect(page.total, 41);
});
test('마지막 쪽 — 커서가 없으면 더 없다(hasNext 여도) · 글 수는 없어도 된다', () {
final ReleaseNotesPage last = ReleaseNotesPage.tryParse(<String, Object?>{
'items': <Object?>[],
'pageInfo': <String, Object?>{'hasNext': false, 'nextCursor': null},
})!;
expect(last.items, isEmpty);
expect(last.hasMore, isFalse);
expect(last.total, isNull);
final ReleaseNotesPage noCursor = ReleaseNotesPage.tryParse(
<String, Object?>{
'items': <Object?>[],
'pageInfo': <String, Object?>{'hasNext': true, 'nextCursor': ''},
},
)!;
expect(noCursor.hasMore, isFalse);
});
test('모양이 틀리면 null — 줄 하나라도(없는 날 · 빈 id · 숫자 제목)', () {
Map<String, Object?> pageOf(List<Object?> items) => <String, Object?>{
'items': items,
'pageInfo': <String, Object?>{'hasNext': false, 'nextCursor': null},
};
expect(
ReleaseNotesPage.tryParse(
pageOf(<Object?>[summary('2026-11-18'), summary('2026-02-30')]),
),
isNull,
);
expect(
ReleaseNotesPage.tryParse(
pageOf(<Object?>[summary('', date: '2026-11-18')]),
),
isNull,
);
expect(
ReleaseNotesPage.tryParse(
pageOf(<Object?>[
<String, Object?>{
'id': '2026-11-18',
'date': '2026-11-18',
'title': 3,
},
]),
),
isNull,
);
expect(
ReleaseNotesPage.tryParse(<String, Object?>{'items': <Object?>[]}),
isNull,
);
expect(ReleaseNotesPage.tryParse('목록'), isNull);
});
test('글 — 묶음(머리 없는 묶음 포함) · 바로 더 옛 글 · 바로 더 새 글', () {
final ReleaseNote note = ReleaseNote.tryParse(<String, Object?>{
...summary('2026-11-05', title: '화면의 안내 글을 다듬었습니다'),
'sections': <Object?>[
<String, Object?>{
'heading': '메일 · 메일함',
'items': <Object?>['한 줄', '두 줄'],
},
<String, Object?>{
'heading': null,
'items': <Object?>['머리 없는 줄'],
},
],
'previous': summary('2026-11-04'),
'next': summary('2026-11-18'),
})!;
expect(note.id, '2026-11-05');
expect(note.date, const CalendarDate(2026, 11, 5));
expect(note.title, '화면의 안내 글을 다듬었습니다');
expect(note.sections, const <ReleaseNoteSection>[
ReleaseNoteSection(heading: '메일 · 메일함', items: <String>['한 줄', '두 줄']),
ReleaseNoteSection(heading: null, items: <String>['머리 없는 줄']),
]);
expect(note.previous?.id, '2026-11-04');
expect(note.next?.id, '2026-11-18');
});
test('다른 글이 없으면(null) · 칸이 아예 없으면 없는 것', () {
final ReleaseNote first = ReleaseNote.tryParse(<String, Object?>{
...summary('2026-11-18'),
'sections': <Object?>[],
'previous': null,
'next': null,
})!;
expect(first.previous, isNull);
expect(first.next, isNull);
final ReleaseNote old = ReleaseNote.tryParse(<String, Object?>{
...summary('2026-11-18'),
'sections': <Object?>[],
})!;
expect(old.previous, isNull);
expect(old.next, isNull);
});
test('글의 모양이 틀리면 null — 묶음 · 다른 글 하나라도', () {
Map<String, Object?> noteWith(Map<String, Object?> extra) =>
<String, Object?>{
...summary('2026-11-18'),
'sections': <Object?>[],
...extra,
};
expect(
ReleaseNote.tryParse(
noteWith(<String, Object?>{
'sections': <Object?>[
<String, Object?>{
'heading': 1,
'items': <Object?>['줄'],
},
],
}),
),
isNull,
);
expect(
ReleaseNote.tryParse(
noteWith(<String, Object?>{
'sections': <Object?>[
<String, Object?>{
'heading': null,
'items': <Object?>['줄', null],
},
],
}),
),
isNull,
);
expect(
ReleaseNote.tryParse(
noteWith(<String, Object?>{
'previous': <String, Object?>{'id': '2026-11-04'},
}),
),
isNull,
);
expect(
ReleaseNote.tryParse(noteWith(<String, Object?>{'next': '2026-11-19'})),
isNull,
);
expect(
ReleaseNote.tryParse(summary('2026-11-18')),
isNull,
reason: '묶음 없음',
);
});
});
group('저장소 · 컨트롤러', () {
late ProviderHarness h;
late FakeReleaseNotesBackend notes;
late ProviderContainer container;
late _RecordingRepository recording;
setUp(() async {
h = ProviderHarness();
notes = FakeReleaseNotesBackend(h.server)..install();
await h.prepare();
await h.store.save(tokensFor(1, now: h.clock.now));
container = ProviderContainer(
overrides: <Override>[
...h.overrides,
releaseNotesAccountProvider.overrideWith(
(Ref ref) => ref.watch(_accountProvider),
),
releaseNotesRepositoryProvider.overrideWith(
(Ref ref) =>
recording = _RecordingRepository(ref.watch(dioProvider)),
),
],
retry: noAutomaticRetry,
);
addTearDown(container.dispose);
});
ReleaseNotesRepository repository() =>
container.read(releaseNotesRepositoryProvider);
/// 목록을 열고(화면이 보는 것처럼 구독한다) 첫 쪽이 끝날 때까지 기다린다
Future<void> openList() async {
final ProviderSubscription<ReleaseNotesListState> subscription = container
.listen(
releaseNotesListProvider,
(ReleaseNotesListState? previous, ReleaseNotesListState next) {},
);
addTearDown(subscription.close);
await pumpEventsUntil(
() =>
container.read(releaseNotesListProvider).status !=
ReleaseNotesListStatus.loading,
reason: '첫 쪽',
);
}
ReleaseNotesListState listState() =>
container.read(releaseNotesListProvider);
/// [condition] 이 된 뒤 한 차례 더 — 응답을 받은 컨트롤러가 상태를 바꿀 자리까지
Future<void> settleAfter(bool Function() condition) async {
await pumpEventsUntil(condition);
await Future<void>.delayed(Duration.zero);
}
test(
'목록 — `GET /release-notes?limit=20`, 다음 쪽은 받은 커서 그대로 · id 는 경로 칸으로 인코딩',
() async {
notes.seed(3);
final ReleaseNotesPage first = await repository().listNotes();
final ReleaseNotesPage next = await repository().listNotes(
cursor: 'n2',
);
await repository().getNote('2026-11-30');
expect(first.items, hasLength(3));
expect(next.items.single.id, '2026-11-28');
expect(notes.listRequests.first.queryParameters, <String, Object?>{
'limit': kReleaseNotesPageSize,
});
expect(notes.listRequests.last.queryParameters['cursor'], 'n2');
expect(
ReleaseNotesApiPaths.releaseNote('2026/11 18'),
'/release-notes/2026%2F11%2018',
);
expect(notes.noteRequests, <String>['2026-11-30']);
},
);
test(
'응답 모양이 틀리면 SYS_001 — 인터셉터를 다 지난 뒤라 따로 가른다(ReleaseNotesMalformedResponse)',
() async {
notes.nextListResponse = FakeResponse.ok(<String, Object?>{'items': 1});
final Object error = await errorOf(repository().listNotes());
expect(error, isA<ReleaseNotesMalformedResponse>());
expect(apiErrorOf(error).code, 'SYS_001');
expect(h.notifier.shownCodes, isEmpty, reason: '저장소는 알리지 않는다');
},
);
test('글 — 없는 글은 404 RES_001 이고 알리지 않는다', () async {
final Object error = await errorOf(repository().getNote('2026-01-01'));
expect(apiErrorOf(error).code, 'RES_001');
expect(h.notifier.shownCodes, isEmpty);
});
test('첫 쪽은 알리지 않는다 — 받지 못함을 상태에 두고 [다시 시도]로 다시 받는다', () async {
notes.nextListResponse = FakeResponse.fail(503, 'MAIL_001');
await openList();
expect(listState().status, ReleaseNotesListStatus.failed);
expect(listState().error?.code, 'MAIL_001');
expect(h.notifier.shownCodes, isEmpty);
await container.read(releaseNotesListProvider.notifier).retry();
expect(listState().status, ReleaseNotesListStatus.ready);
expect(listState().items, hasLength(kBoardReleaseNotes.length));
});
test('[더 보기]의 겹친 글은 건너뛴다 — 그사이 글이 더해져 자리가 밀렸을 때', () async {
notes.seed(21);
await openList();
// 새 글이 맨 위에 더해졌다 — 다음 쪽(21번째 자리부터)에 이미 받은 마지막 줄이 다시 온다
notes.notes = <FakeReleaseNote>[
const FakeReleaseNote('2026-12-01', '업데이트 22'),
...notes.notes,
];
await container.read(releaseNotesListProvider.notifier).loadMore();
final List<String> ids = <String>[
for (final ReleaseNoteSummary note in listState().items) note.id,
];
expect(ids, hasLength(21));
expect(ids.toSet(), hasLength(21));
expect(listState().hasMore, isFalse);
});
test('세션이 바뀌면 처음부터 — 앞 세션의 늦은 응답은 버린다', () async {
notes.seed(2);
final Completer<void> late = Completer<void>();
int calls = 0;
notes.onList = (RequestOptions options) async {
calls++;
if (calls == 1) {
await late.future;
return FakeResponse.ok(<String, Object?>{
'items': <Object?>[
const FakeReleaseNote('2020-01-01', '앞 세션의 글').summaryJson(),
],
'pageInfo': <String, Object?>{'hasNext': false, 'nextCursor': null},
});
}
return notes.listPage(options);
};
final ProviderSubscription<ReleaseNotesListState> subscription = container
.listen(
releaseNotesListProvider,
(ReleaseNotesListState? previous, ReleaseNotesListState next) {},
);
addTearDown(subscription.close);
await pumpEventsUntil(() => notes.listRequests.length == 1);
container.read(_accountProvider.notifier).set('other@arasystem.it.kr');
await pumpEventsUntil(
() => listState().status == ReleaseNotesListStatus.ready,
reason: '새 세션의 첫 쪽',
);
expect(notes.listRequests, hasLength(2));
expect(listState().items.map((ReleaseNoteSummary n) => n.title), <String>[
'업데이트 2',
'업데이트 1',
]);
late.complete();
await settleAfter(() => recording.listsFinished == 2);
expect(listState().status, ReleaseNotesListStatus.ready);
expect(listState().items.map((ReleaseNoteSummary n) => n.title), <String>[
'업데이트 2',
'업데이트 1',
]);
});
test('로그인하지 않았으면(세션 없음) 묻지 않는다', () async {
container.read(_accountProvider.notifier).set(null);
final ProviderSubscription<ReleaseNotesListState> list = container.listen(
releaseNotesListProvider,
(ReleaseNotesListState? previous, ReleaseNotesListState next) {},
);
addTearDown(list.close);
final ProviderSubscription<ReleaseNoteState> note = container.listen(
releaseNoteProvider('2026-11-18'),
(ReleaseNoteState? previous, ReleaseNoteState next) {},
);
addTearDown(note.close);
await Future<void>.delayed(Duration.zero);
expect(listState().status, ReleaseNotesListStatus.loading);
expect(notes.listRequests, isEmpty);
expect(notes.noteRequests, isEmpty);
});
test('글 — 세션이 바뀌면 다시 받고, 앞 세션의 늦은 응답(실패)은 버린다', () async {
final Completer<void> late = Completer<void>();
int calls = 0;
notes.onNote = (RequestOptions options) async {
calls++;
if (calls == 1) {
await late.future;
return FakeResponse.fail(500, 'SYS_001');
}
return notes.noteDetail('2026-11-05');
};
final ProviderSubscription<ReleaseNoteState> subscription = container
.listen(
releaseNoteProvider('2026-11-05'),
(ReleaseNoteState? previous, ReleaseNoteState next) {},
);
addTearDown(subscription.close);
await pumpEventsUntil(() => notes.noteRequests.length == 1);
container.read(_accountProvider.notifier).set('other@arasystem.it.kr');
await pumpEventsUntil(
() =>
container.read(releaseNoteProvider('2026-11-05')).status ==
ReleaseNoteStatus.ready,
reason: '새 세션의 글',
);
expect(notes.noteRequests, hasLength(2));
late.complete();
await settleAfter(() => recording.notesFinished == 2);
final ReleaseNoteState state = container.read(
releaseNoteProvider('2026-11-05'),
);
expect(state.status, ReleaseNoteStatus.ready);
expect(state.note?.title, '화면의 안내 글을 다듬었습니다');
expect(state.error, isNull);
});
});
}
@@ -8,6 +8,9 @@ import 'package:arasystem_app/core/push/push_messaging.dart';
import 'package:arasystem_app/core/push/push_preferences.dart';
import 'package:arasystem_app/core/push/push_texts.dart';
import 'package:arasystem_app/features/auth/presentation/pages/login_page.dart';
import 'package:arasystem_app/features/settings/presentation/pages/release_notes_page.dart';
import 'package:arasystem_app/features/settings/presentation/pages/settings_page.dart';
import 'package:arasystem_app/features/settings/presentation/release_notes_texts.dart';
import 'package:arasystem_app/features/settings/presentation/settings_texts.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -16,6 +19,7 @@ import 'package:url_launcher/url_launcher.dart';
import '../../support/fakes.dart';
import '../../support/login_fakes.dart';
import '../../support/push_screen_harness.dart';
import '../../support/release_notes_fakes.dart';
/// 설정 탭 — 앱 보드 `App1Shell`(설정 한 화면 · 로그아웃 확인 창)과 `App1Push` ④(새 메일 알림 줄)
void main() {
@@ -102,6 +106,31 @@ void main() {
expect(find.byType(LicensePage), findsOneWidget);
expect(find.byType(NavigationBar), findsNothing);
});
testWidgets(
'[업데이트 내역]은 탭 막대 없이 전체 화면 목록으로 연다 — 뒤로 오면 설정 (보드 ReleaseNotes B1)',
(WidgetTester tester) async {
final FakeReleaseNotesBackend notes = FakeReleaseNotesBackend(h.server)
..install();
await h.pump(tester);
await h.openSettings(tester);
await tapVisible(tester, find.text(ReleaseNotesTexts.title));
expect(find.byType(ReleaseNotesPage), findsOneWidget);
expect(find.byType(NavigationBar), findsNothing);
expect(findCopy(kBoardReleaseNotes.first.title), findsOneWidget);
expect(notes.listRequests, hasLength(1));
await tester.tap(find.byTooltip(ReleaseNotesTexts.back));
await settle(tester);
expect(find.byType(ReleaseNotesPage), findsNothing);
expect(find.byType(SettingsPage), findsOneWidget);
expect(find.byType(NavigationBar), findsOneWidget);
expect(h.app.location(), '/settings');
},
);
});
group('설정 › 앱 정보 › [새 버전 받기] — 앱 보드 App1Push ④', () {
@@ -139,42 +168,63 @@ void main() {
Finder snack(String text) =>
find.descendant(of: find.byType(SnackBar), matching: findCopy(text));
testWidgets('줄 차례 — 앱 버전 · 새 버전 받기 · 오픈소스 라이선스, 끝 아이콘은 바깥으로 여는 아이콘', (
WidgetTester tester,
) async {
await h.pump(tester);
await h.openSettings(tester);
testWidgets(
'줄 차례 — 앱 버전 · 업데이트 내역 · 새 버전 받기 · 오픈소스 라이선스, 끝 아이콘은 바깥으로 여는 아이콘',
(WidgetTester tester) async {
await h.pump(tester);
await h.openSettings(tester);
final double version = tester
.getTopLeft(find.text(SettingsTexts.appVersion))
.dy;
final double download = tester
.getTopLeft(find.text(AppDownloadTexts.getNewVersion))
.dy;
final double licenses = tester
.getTopLeft(find.text(SettingsTexts.licenses))
.dy;
expect(version, lessThan(download));
expect(download, lessThan(licenses));
expect(
rowIcon(AppDownloadTexts.getNewVersion, Icons.open_in_new),
findsOneWidget,
);
expect(
rowIcon(AppDownloadTexts.getNewVersion, Icons.chevron_right),
findsNothing,
);
expect(
rowIcon(SettingsTexts.licenses, Icons.chevron_right),
findsOneWidget,
);
expect(
tester.getSize(downloadRow()).height,
greaterThanOrEqualTo(52),
reason: '한 줄 칸 52 — 앱 버전 · 오픈소스 라이선스와 같다',
);
expect(launched, isEmpty);
});
final double version = tester
.getTopLeft(find.text(SettingsTexts.appVersion))
.dy;
final double releaseNotes = tester
.getTopLeft(find.text(ReleaseNotesTexts.title))
.dy;
final double download = tester
.getTopLeft(find.text(AppDownloadTexts.getNewVersion))
.dy;
final double licenses = tester
.getTopLeft(find.text(SettingsTexts.licenses))
.dy;
expect(version, lessThan(releaseNotes));
expect(releaseNotes, lessThan(download));
expect(download, lessThan(licenses));
expect(
rowIcon(ReleaseNotesTexts.title, Icons.chevron_right),
findsOneWidget,
reason: '앱 안의 화면 — 오픈소스 라이선스 줄과 같은 꼴',
);
expect(
tester
.getSize(
find.ancestor(
of: find.text(ReleaseNotesTexts.title),
matching: find.byType(InkWell),
),
)
.height,
greaterThanOrEqualTo(52),
);
expect(
rowIcon(AppDownloadTexts.getNewVersion, Icons.open_in_new),
findsOneWidget,
);
expect(
rowIcon(AppDownloadTexts.getNewVersion, Icons.chevron_right),
findsNothing,
);
expect(
rowIcon(SettingsTexts.licenses, Icons.chevron_right),
findsOneWidget,
);
expect(
tester.getSize(downloadRow()).height,
greaterThanOrEqualTo(52),
reason: '한 줄 칸 52 — 앱 버전 · 오픈소스 라이선스와 같다',
);
expect(launched, isEmpty);
},
);
testWidgets('누르면 업데이트 화면과 같은 받기 화면을 바깥 브라우저로 연다 — 설정에 머문다', (
WidgetTester tester,
@@ -0,0 +1,203 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:arasystem_app/features/settings/data/repositories/release_notes_repository.dart';
import 'package:dio/dio.dart';
import 'fakes.dart';
/// 가짜 업데이트 내역 글 하나 — 서버가 읽어 둔 글(계약 7절의 응답 칸)
class FakeReleaseNote {
const FakeReleaseNote(this.id, this.title, {String? date, this.sections})
: _date = date;
final String id;
final String title;
final String? _date;
/// 묶음 — 없으면 머리 없는 묶음 하나에 줄 하나
final List<Map<String, Object?>>? sections;
/// 쓴 날 — 없으면 id 의 날짜 칸(`2026-11-18-2` → `2026-11-18`)
String get date => _date ?? id.substring(0, _dateLength);
static const int _dateLength = 10;
/// 목록 줄 · 다른 글 줄의 꼴 `{ id, date, title }`
Map<String, Object?> summaryJson() => <String, Object?>{
'id': id,
'date': date,
'title': title,
};
/// 글 화면의 묶음
List<Map<String, Object?>> sectionsJson() =>
sections ??
<Map<String, Object?>>[
<String, Object?>{
'heading': null,
'items': <String>['$title — 본문'],
},
];
}
/// 묶음 하나의 꼴
Map<String, Object?> releaseNoteSectionJson(
String? heading,
List<String> items,
) => <String, Object?>{'heading': heading, 'items': items};
/// 보드 `ReleaseNotes` 의 네 글(맨 위 글은 다음 배포의 첫 글 초안 — 최근 글부터)
const List<FakeReleaseNote> kBoardReleaseNotes = <FakeReleaseNote>[
FakeReleaseNote(
'2026-11-18',
'메일 검색이 빨라지고 휴대폰 앱이 나왔습니다',
sections: <Map<String, Object?>>[
<String, Object?>{
'heading': '휴대폰 앱',
'items': <String>[
'안드로이드 휴대폰 앱이 나왔습니다. 웹메일의 [설정] → [휴대폰 앱]에서 받을 수 있습니다.',
'앱에서 메일을 읽고 쓰고 찾을 수 있고, 새 메일이 오면 휴대폰으로 알려 드립니다. 알림에는 새 메일이 몇 통인지만 나오고, 보낸 사람이나 제목은 나오지 않습니다.',
],
},
<String, Object?>{
'heading': '메일 검색',
'items': <String>['찾은 말에는 노란 표시가 붙습니다.'],
},
],
),
FakeReleaseNote(
'2026-11-05',
'화면의 안내 글을 다듬었습니다',
sections: <Map<String, Object?>>[
<String, Object?>{
'heading': null,
'items': <String>[
'단추와 안내 글을 더 알기 쉬운 말로 고쳤습니다.',
'브라우저 탭과 휴대폰 홈 화면의 아이콘을 ARA SYSTEM 마크로 바꿨습니다.',
],
},
],
),
FakeReleaseNote('2026-11-04', '왼쪽 메뉴를 정리했습니다'),
FakeReleaseNote('2026-11-03', '2단계 인증과 앱 비밀번호를 쓸 수 있습니다'),
];
/// 가짜 업데이트 내역 서버 — `GET /release-notes`(커서 = 다음 글의 자리) · `GET /release-notes/{id}` (계약 7절)
class FakeReleaseNotesBackend {
FakeReleaseNotesBackend(this.server);
final FakeServer server;
/// 글 — 최근 글부터(서버 차례)
List<FakeReleaseNote> notes = kBoardReleaseNotes;
/// 받은 목록 요청 · 글 요청의 id (차례대로)
final List<RequestOptions> listRequests = <RequestOptions>[];
final List<String> noteRequests = <String>[];
/// 두면 목록 · 글 요청이 이것이 끝날 때까지 기다린다(불러오는 중 · [더 보기]를 받는 중)
Completer<void>? listGate;
Completer<void>? noteGate;
/// 두면 다음 목록 · 글 요청 하나에 이것으로 답한다(한 번 쓰고 지운다) — 실패 · 모양이 틀린 응답
FakeResponse? nextListResponse;
FakeResponse? nextNoteResponse;
/// 두면 목록 · 글 요청에 이것으로 답한다(기본 답은 [listPage] · [noteDetail]) — 요청마다 다른 때에 답하는 시험
FakeHandler? onList;
FakeHandler? onNote;
/// 글 [count] 개 — 2026-11-30 부터 하루씩 거슬러(최근 글부터), 제목 "업데이트 N"(가장 새 글이 N = count)
void seed(int count) {
final DateTime newest = DateTime.utc(2026, 11, 30);
notes = <FakeReleaseNote>[
for (int index = 0; index < count; index++)
FakeReleaseNote(
_dateOf(newest.subtract(Duration(days: index))),
'업데이트 ${count - index}',
),
];
}
static String _dateOf(DateTime day) =>
'${day.year}-${day.month.toString().padLeft(2, '0')}-'
'${day.day.toString().padLeft(2, '0')}';
/// 커서의 머리 — 커서는 다음 글의 자리(`n20`)
static const String cursorPrefix = 'n';
static final RegExp _notePath = RegExp(r'^/release-notes/([^/]+)$');
/// 목록 한 쪽의 기본 답 — `limit`(없으면 20)개씩, 커서 자리부터
FakeResponse listPage(RequestOptions options) {
final Object? rawLimit = options.queryParameters['limit'];
final int limit = rawLimit is int ? rawLimit : kReleaseNotesPageSize;
final Object? cursor = options.queryParameters['cursor'];
final int start = cursor is String
? int.parse(cursor.substring(cursorPrefix.length))
: 0;
final int end = math.min(start + limit, notes.length);
final bool hasNext = end < notes.length;
return FakeResponse.ok(<String, Object?>{
'items': <Map<String, Object?>>[
for (final FakeReleaseNote note in notes.sublist(start, end))
note.summaryJson(),
],
'pageInfo': <String, Object?>{
'hasNext': hasNext,
'nextCursor': hasNext ? '$cursorPrefix$end' : null,
'total': notes.length,
},
});
}
/// 글 하나의 기본 답 — 없는 글은 404 `RES_001`, 다른 글은 목록 차례의 위(next) · 아래(previous)
FakeResponse noteDetail(String id) {
final int index = notes.indexWhere((FakeReleaseNote n) => n.id == id);
if (index < 0) return FakeResponse.fail(404, 'RES_001');
final FakeReleaseNote note = notes[index];
return FakeResponse.ok(<String, Object?>{
...note.summaryJson(),
'sections': note.sectionsJson(),
'previous': index + 1 < notes.length
? notes[index + 1].summaryJson()
: null,
'next': index > 0 ? notes[index - 1].summaryJson() : null,
});
}
void install() {
server.on('GET', ReleaseNotesApiPaths.releaseNotes, (
RequestOptions options,
) async {
listRequests.add(options);
final Completer<void>? gate = listGate;
if (gate != null) await gate.future;
final FakeResponse? injected = nextListResponse;
if (injected != null) {
nextListResponse = null;
return injected;
}
final FakeHandler? handler = onList;
if (handler != null) return handler(options);
return listPage(options);
});
server.onPattern('GET', _notePath, (RequestOptions options) async {
final String id = Uri.decodeComponent(
_notePath.firstMatch(options.path)!.group(1)!,
);
noteRequests.add(id);
final Completer<void>? gate = noteGate;
if (gate != null) await gate.future;
final FakeResponse? injected = nextNoteResponse;
if (injected != null) {
nextNoteResponse = null;
return injected;
}
final FakeHandler? handler = onNote;
if (handler != null) return handler(options);
return noteDetail(id);
});
}
}
+13 -1
View File
@@ -4,8 +4,10 @@
// [보내기 ▾](9단계): 단추를 둘로 나눈다 — 왼쪽은 지금의 [보내기], 오른쪽 ▾(44 — 보드는 40, 밀도 예외를 새로 만들지 않는다)는 메뉴 「예약 발송…」(시계).
// 메뉴는 편집기 메뉴(EditorMenu)를 쓴다 — 키보드로 열면(Enter · Space · 아래 화살표) 첫 항목에, 마우스로 열면 메뉴 자체에 초점, Esc 는 ▾ 로 돌아간다.
// 두 단추는 같은 규칙으로 함께 꺼진다(보내는 중 · 편집기를 불러오는 중 등 — canSend · 첨부의 까닭 — blockedReason)
// 메뉴는 단추 아래로 열되 보이는 영역 아래가 모자라면 위로 연다(usePopupPlacement) — 단추 줄은 편집기가 늘어나 화면 맨 아래에 붙으므로
// 메뉴는 단추 아래로 열되 보이는 영역 아래가 모자라면 위로 연다(usePopupPlacement) — 단추 줄은 화면 맨 아래에 붙으므로
// 보통은 위로 열린다 (아래로 열면 화면 밖에 생기고, 초점이 들어갈 때 쓰기 화면이 튀어 올랐다 — 2026-10-29 리뷰)
// 줄은 스크롤하는 칸(넓은 화면은 쓰기 칸, 좁은 화면은 문서)의 아래에 붙는다(position: sticky) — 받는 사람이 많거나 화면이 낮아 쓰기 칸이 넘쳐도
// [보내기]가 늘 보인다 (2026-11-10 사용자 보고 — 전 직원을 숨은참조에 넣자 이 줄이 화면 밖으로 숨었다). 겹침 차례는 아래 style 의 주석
// 꺼진 [보내기] · ▾ 는 disabled 가 아니라 aria-disabled 다(11단계 — 초점은 받는다). 첨부 때문에 막혔으면(올리는 중 · 다 올리지 못함 · 빼야 하는 파일)
// [임시 저장] 오른쪽에 까닭 글(ⓘ 12.5 — 두 단추의 aria-describedby #compose-send-why)을 두고, 누르면 보내지 않고 까닭을 화면 낭독기에 읽어 준다(role=status).
// 「예약 발송…」도 같다. 임시 저장은 그대로 된다 (디자인 README "11단계 › 다 올릴 때까지 보내기를 막는다")
@@ -227,13 +229,23 @@ watch(unavailable, (off) => {
</template>
<style scoped>
/*
* 스크롤하는 칸의 아래에 붙는다 — 모양(위 선 · 흰 바탕 · 여백)은 그대로이고 넘치는 동안만 내용 위에 떠 있다 (2026-11-10 사용자 보고).
* 겹침 차례: 편집기 툴바(2) < 이 줄(3) < 받는 사람 추천 목록 · 메뉴를 연 툴바(4) — 툴바는 이 줄 밑으로 지나가고,
* 추천 목록과 툴바의 메뉴는 이 줄 위에 뜬다. 이 줄의 메뉴([보내기 ▾] · [서명 ▾])는 이 줄 안에서 위로 열려 툴바 위에 온다.
* 위치를 잡은 줄이라 안의 화면 낭독 글(.visually-hidden)도 이 줄 안에 든다 — 넓은 화면에서 문서가 스크롤되지 않게
*/
.compose__footer {
position: sticky;
bottom: 0;
z-index: 3;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-8);
padding: var(--space-10) var(--content-inline);
border-top: var(--border-width) solid var(--color-border);
background: var(--color-surface);
}
/* [보내기 ▾] — 둘을 한 덩어리로 (디자인 원본 MailSendLater: 모서리 8 · 사이에 옅은 흰 선). 메뉴는 이 덩어리 아래(모자라면 위)에서 연다 */
@@ -3,6 +3,7 @@
// - 칸은 메일 표(MessageTableRow)와 같은 자리 · 폭이다. 크기 칸은 없다 — 대화 하나의 크기는 마지막 메일의 크기가 아니다
// - 참여자 뒤에 회색 "4통"(한 통이면 없다) — 참여자가 길면 이름이 먼저 줄고 통 수는 남는다
// - 행 어디를 눌러도 그 메일함의 마지막 메일이 열리고(open), 제목은 키보드로 갈 수 있는 링크다. Ctrl(⌘) · Shift + 클릭은 고른다 (메일 표와 같다)
// 고르기 · 별 칸은 어디를 눌러도 열지 않는다 — 고르기 칸은 빈자리도 고르기 상자를 누른 것과 같고, 별 칸의 빈자리는 아무 일도 없다 (2026-11-10 사용자 보고)
// - 줄의 메일이 모두 보내기를 기다리는 메일이면 고르기 · 별을 쓸 수 없다(aria-disabled + 까닭은 title)
// - 임시보관함의 제목 없는 저장본은 메일 표처럼 흐린 "(제목 없음)" (리뷰 L5)
// 칸 폭 · 칸 공통 여백은 표(MessageTable)가 정한다.
@@ -18,6 +19,7 @@ import { MAIL_BULK_TEXT, MAIL_LIST_ROW_TEXT, MAIL_NO_SUBJECT } from '@/shared/co
import { MAIL_CONVERSATION_LIST_TEXT } from '@/shared/constants/mail-conversation'
import { SEND_LATER_TEXT } from '@/shared/constants/mail-send-later'
import type { ConversationSummary } from '@/shared/types/mail'
import { eventPathHas } from '@/shared/utils/event-path'
import { formatMailTableDate } from '@/shared/utils/format'
import { conversationCountText } from '@/shared/utils/mail-conversation-list'
import { pendingSendLabel, type RowWho } from '@/shared/utils/mail-list-row'
@@ -50,6 +52,13 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<Emits>()
/** 스스로 처리하는 것 — 고르기 · 별 단추 (메일 표와 같다) */
const ROW_CONTROLS = 'button, input'
/** 고르기 칸 — 단추 밖의 빈자리를 눌러도 고르기 상자를 누른 것과 같다 */
const CHECK_CELL = '.mail-table__check'
/** 눌러도 줄을 열지 않는 곳 — 제목 링크 · 별 칸(단추 밖의 빈자리 — 아무 일도 없다) */
const ROW_NO_OPEN = 'a, .mail-table__star'
const drag = useMailRowDrag()
const subject = computed(() => props.row.latest.subject || MAIL_NO_SUBJECT)
@@ -57,16 +66,23 @@ const countText = computed(() => conversationCountText(props.row))
/** 마지막 메일이 붙잡아 둔 발송이면 "예약 · …" · "보내는 중" (9단계) */
const pendingLabel = computed(() => pendingSendLabel(props.row.latest))
/** 행 어디를 눌러도 연다 — 버튼 · 제목 링크(스스로 연다) · 글자를 고르는 중이면 그대로 둔다 (메일 표와 같다) */
/**
* 행 어디를 눌러도 연다 — 단추 · 제목 링크(스스로 연다) · 별 칸의 빈자리 · 글자를 고르는 중이면 그대로 둔다 (메일 표와 같다).
* 고르기 칸의 빈자리(단추 밖)는 고르기 상자를 누른 것과 같다 — 고르고(Shift 는 범위) 열지 않는다, 고를 수 없는 줄이면 아무 일도 없다.
* 무엇을 눌렀는지는 사건이 지나간 길로 가른다 — 고르기 상자 그림은 고르면 다시 그려져 target 이 문서에서 떨어진다 (2026-11-10 사용자 보고)
*/
function onRowClick(event: MouseEvent) {
const target = event.target instanceof Element ? event.target : null
if (target?.closest('button')) return
if (eventPathHas(event, ROW_CONTROLS)) return
if (eventPathHas(event, CHECK_CELL)) {
if (!props.held) emit('check', props.row.id, event.shiftKey)
return
}
if (event.ctrlKey || event.metaKey || event.shiftKey) {
event.preventDefault()
if (!props.held) emit('check', props.row.id, event.shiftKey)
return
}
if (target?.closest('a')) return
if (eventPathHas(event, ROW_NO_OPEN)) return
if (window.getSelection()?.toString()) return
emit('open', props.to)
}
@@ -215,6 +231,11 @@ function onStar() {
color: var(--color-star);
}
/* 별 그림은 누름을 받지 않는다 — 누른 것(target)이 늘 단추다 (메일 표와 같다) */
.thread-table-row__star .app-icon {
pointer-events: none;
}
.thread-table-row__star[aria-disabled='true'] {
cursor: not-allowed;
}
@@ -94,10 +94,15 @@ function onClick(event: MouseEvent) {
background: transparent;
}
/*
* 상자 그림은 누름을 받지 않는다 — 누른 것(target)이 늘 단추다. 고르면 그림이 바뀌며(빈 상자 → 채운 상자) 누른 <rect> 가 문서에서 떨어져,
* 줄 전체에 click 을 건 목록 보기가 단추를 누른 줄 모르고 메일까지 열었다 (2026-11-10 사용자 보고 — 줄은 사건의 길로도 가른다, event-path.ts)
*/
.mail-checkbox__box {
flex: 0 0 auto;
width: var(--size-checkbox);
height: var(--size-checkbox);
pointer-events: none;
}
.mail-checkbox__empty {
@@ -0,0 +1,225 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import type { Component } from 'vue'
import type { ConversationSummary, MessageSummary } from '@/shared/types/mail'
import { flushPromises } from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
import ConversationTableRow from './ConversationTableRow.vue'
import MessageListItem from './MessageListItem.vue'
import MessageTableRow from './MessageTableRow.vue'
// 고르기 칸을 누르면 메일까지 열렸다 (2026-11-10 사용자 보고 — 목록 보기에서 고르기 상자 가운데를 눌렀다).
// 목록 보기의 줄(tr)은 줄 전체에 click 을 걸고 "단추 · 링크를 눌렀는가"를 target.closest 로 갈랐다. 상자 가운데를 누르면 target 은 빈 상자 그림(<rect>)이고,
// 단추의 처리기가 먼저 돌아 고르기를 바꾸면 브라우저는 처리기 사이에 microtask 를 돌려 Vue 가 다시 그린다 — 빈 상자 그림이 빠져 target 이 문서에서 떨어지고,
// 줄의 처리기에서 closest('button') 이 비어 줄을 누른 것으로 보고 열었다. 고르기 칸 · 별 칸의 빈자리(단추 밖)를 눌러도 열었다.
// 이제 줄은 사건이 지나간 길(event-path.ts — dispatch 때 정해진다)로 가르고, 상자 · 별 그림은 누름을 받지 않는다(pointer-events: none — 브라우저 시험).
// 고르기 칸의 빈자리는 고르기 상자를 누른 것과 같고(고를 수 없는 줄이면 아무 일도 없다), 별 칸의 빈자리는 아무 일도 없다.
// 스크립트의 dispatchEvent 는 처리기 사이에 microtask 를 돌리지 않는다 — 단추와 줄 사이(칸의 처리기)에서 그림을 떼어 내 브라우저의 다시 그리기를 흉내 낸다
function summary(id: string, partial: Partial<MessageSummary> = {}): MessageSummary {
return {
id,
threadId: `t-${id}`,
mailboxIds: ['inbox'],
from: { name: '이도윤', email: 'doyun.lee@arasystem.test' },
to: [{ name: '김아라', email: 'ara.kim@arasystem.test' }],
subject: `제목 ${id}`,
preview: '',
receivedAt: '2026-11-10T01:00:00Z',
isRead: true,
isFlagged: false,
hasAttachment: false,
size: 2048,
...partial,
}
}
function conversation(id: string): ConversationSummary {
const latest = summary(`${id}-latest`)
return {
id,
latest,
participants: [{ name: '이도윤', email: 'doyun.lee@arasystem.test' }],
messageCount: 3,
messageIds: [latest.id, `${id}-2`, `${id}-3`],
pendingSendIds: [],
isRead: true,
isFlagged: false,
hasAttachment: false,
}
}
interface RowCase {
name: string
component: Component
props: Record<string, unknown>
/** 고를 수 없는 줄로 바꾸는 props — 보내기를 기다리는 메일(9단계) */
heldProps: Record<string, unknown>
/** 고르기 · 별 사건에 실리는 줄 id */
rowId: string
}
const ROWS: RowCase[] = [
{
name: '메일 줄 (MessageTableRow)',
component: MessageTableRow,
props: { message: summary('m1'), to: '/mail/inbox/m1', role: 'inbox' },
heldProps: { message: summary('m1', { pendingSend: { kind: 'scheduled', sendAt: '2026-11-11T00:00:00Z' } }) },
rowId: 'm1',
},
{
name: '대화 줄 (ConversationTableRow — 대화로 묶기)',
component: ConversationTableRow,
props: {
row: conversation('c1'),
who: { text: '이도윤', placeholder: false },
to: '/mail/inbox/c1-latest',
},
heldProps: { held: true },
rowId: 'c1',
},
]
describe('목록 보기의 줄 — 고르기 칸 · 별 칸을 눌러도 메일을 열지 않는다 (2026-11-10 사용자 보고)', () => {
let pinia: Pinia
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
})
async function mountRow(row: RowCase): Promise<VueWrapper> {
const router = createTestRouter()
await router.push('/mail/inbox')
await router.isReady()
return mount(row.component, { props: row.props, global: { plugins: [pinia, router] } })
}
/** 누른 그림을 단추와 줄 사이(칸의 처리기)에서 떼어 낸다 — 브라우저에서 단추의 처리기 뒤 Vue 가 다시 그린 것처럼 */
function detachBeforeRow(wrapper: VueWrapper, cellSelector: string, picture: Element) {
wrapper.get(cellSelector).element.addEventListener('click', () => picture.remove())
}
function click(element: Element, init: MouseEventInit = {}) {
element.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, ...init }))
}
describe.each(ROWS)('$name', (row) => {
it('고르기 상자 그림(빈 상자)을 누르면 고르기만 한다 — 고르며 다시 그려 그림이 문서에서 떨어져도 열지 않는다', async () => {
const wrapper = await mountRow(row)
const picture = wrapper.get('td.mail-table__check .mail-checkbox__empty').element
detachBeforeRow(wrapper, 'td.mail-table__check', picture)
click(picture)
await flushPromises()
expect(picture.isConnected).toBe(false)
expect(wrapper.emitted('check')).toEqual([[row.rowId, false]])
expect(wrapper.emitted('open')).toBeUndefined()
})
it('Shift + 상자 그림은 범위로 고른다 — 열지 않는다', async () => {
const wrapper = await mountRow(row)
const picture = wrapper.get('td.mail-table__check .mail-checkbox__empty').element
detachBeforeRow(wrapper, 'td.mail-table__check', picture)
click(picture, { shiftKey: true })
expect(wrapper.emitted('check')).toEqual([[row.rowId, true]])
expect(wrapper.emitted('open')).toBeUndefined()
})
it('고르기 칸의 빈자리(단추 밖)는 고르기 상자를 누른 것과 같다 — 고르고 · Shift 는 범위 · Ctrl(⌘)은 하나씩, 열지 않는다', async () => {
const wrapper = await mountRow(row)
const cell = wrapper.get('td.mail-table__check').element
click(cell)
click(cell, { shiftKey: true })
click(cell, { ctrlKey: true })
click(cell, { metaKey: true })
expect(wrapper.emitted('check')).toEqual([
[row.rowId, false],
[row.rowId, true],
[row.rowId, false],
[row.rowId, false],
])
expect(wrapper.emitted('open')).toBeUndefined()
})
it('고를 수 없는 줄(보내기를 기다리는 메일)이면 고르기 칸의 빈자리를 눌러도 아무 일도 없다', async () => {
const wrapper = await mountRow({ ...row, props: { ...row.props, ...row.heldProps } })
click(wrapper.get('td.mail-table__check').element)
click(wrapper.get('td.mail-table__check').element, { shiftKey: true })
expect(wrapper.emitted('check')).toBeUndefined()
expect(wrapper.emitted('open')).toBeUndefined()
})
it('별 칸의 빈자리(단추 밖)는 아무 일도 없다 — 열지도 · 별을 바꾸지도 · 고르지도 않는다', async () => {
const wrapper = await mountRow(row)
click(wrapper.get('td.mail-table__star').element)
expect(wrapper.emitted('open')).toBeUndefined()
expect(wrapper.emitted('check')).toBeUndefined()
expect(wrapper.emitted('toggle-flag')).toBeUndefined()
})
it('별 그림을 누르면 중요 표시만 한다 — 그림이 떨어져도 열지 않는다', async () => {
const wrapper = await mountRow(row)
const picture = wrapper.get('td.mail-table__star svg').element
detachBeforeRow(wrapper, 'td.mail-table__star', picture)
click(picture)
expect(wrapper.emitted('toggle-flag')).toEqual([[row.rowId]])
expect(wrapper.emitted('open')).toBeUndefined()
})
it('줄의 다른 곳(사람 · 날짜 칸)을 누르면 지금처럼 연다 · Ctrl(⌘) · Shift + 클릭은 열지 않고 고른다', async () => {
const wrapper = await mountRow(row)
const cells = wrapper.findAll('td')
click(cells[2]!.element)
expect(wrapper.emitted('open')).toHaveLength(1)
click(wrapper.get('td.mail-table__date').element, { ctrlKey: true })
click(cells[2]!.element, { shiftKey: true })
expect(wrapper.emitted('check')).toEqual([
[row.rowId, false],
[row.rowId, true],
])
expect(wrapper.emitted('open')).toHaveLength(1)
})
})
})
describe('분할 보기의 줄 — 고르기 칸(36)은 링크 밖이라 열지 않는다 (지금처럼)', () => {
it('상자 그림을 눌러도 고르기만 하고 주소는 그대로다 · 줄(링크)을 누르면 연다', async () => {
setActivePinia(createPinia())
const router = createTestRouter()
await router.push('/mail/inbox')
await router.isReady()
const wrapper = mount(MessageListItem, {
props: { message: summary('m1'), to: '/mail/inbox/m1', role: 'inbox' },
global: { plugins: [router] },
})
click(wrapper.get('.message-row__check .mail-checkbox__empty').element)
await flushPromises()
expect(wrapper.emitted('check')).toEqual([['m1', false]])
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
await wrapper.get('.message-row__main').trigger('click')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m1')
})
function click(element: Element) {
element.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
}
})
@@ -1,6 +1,8 @@
<script setup lang="ts">
// 목록 보기 표의 한 행(38) — 고르기 · 별 · 보낸 사람 · [메일함] · 제목(링크 + 첨부 클립) · 날짜 · 크기 (디자인 원본 MainList · BulkSelect)
// - 행 어디를 눌러도 메일이 열리고(open), 제목은 키보드로 갈 수 있는 링크다. 고르기 · 별 버튼은 따로 동작한다
// - 행 어디를 눌러도 메일이 열리고(open), 제목은 키보드로 갈 수 있는 링크다. 고르기 · 별 버튼은 따로 동작한다 —
// 고르기 · 별 칸은 어디를 눌러도(상자 · 별 그림 위 · 단추 밖의 빈자리) 열지 않는다 — 고르기 칸은 빈자리도 고르기 상자를 누른 것과 같고,
// 별 칸의 빈자리는 아무 일도 없다 (2026-11-10 사용자 보고 — 고르기 상자 가운데를 누르면 메일까지 열렸다)
// - Ctrl(⌘)+클릭은 하나씩, Shift+클릭은 범위로 고른다 — 메일을 열지 않고, 제목 링크를 눌러도 새 탭 · 새 창으로 열지 않는다
// - 보낸 사람 자리 · 읽음 표시 · 누르면 갈 곳은 메일함 규칙을 따른다 (mail-list-row.ts)
// - 아직 보내지 않은 내 메일(9단계)은 날짜 칸이 "예약 · 10월 30일 오전 9:00" · "보내는 중"(청록) — 칸(84)보다 길면 줄이고 전체는 title 로.
@@ -19,6 +21,7 @@ import { useMailRowDrag } from '@/composables/useMailDrag'
import { MAIL_BULK_TEXT, MAIL_NO_SUBJECT } from '@/shared/constants/mail'
import { SEND_LATER_TEXT } from '@/shared/constants/mail-send-later'
import type { MailboxRole, MessageListItem } from '@/shared/types/mail'
import { eventPathHas } from '@/shared/utils/event-path'
import { formatFileSize, formatMailTableDate } from '@/shared/utils/format'
import { isDraftRole, isRowUnread, pendingSendLabel, rowWho } from '@/shared/utils/mail-list-row'
import { isHeldMessage } from '@/shared/utils/pending-send'
@@ -48,6 +51,13 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<Emits>()
/** 스스로 처리하는 것 — 고르기 · 별 단추 (Ctrl · Shift + 클릭으로도 고르지 않는다) */
const ROW_CONTROLS = 'button, input'
/** 고르기 칸 — 단추 밖의 빈자리를 눌러도 고르기 상자를 누른 것과 같다 */
const CHECK_CELL = '.mail-table__check'
/** 눌러도 줄을 열지 않는 곳 — 제목 링크(스스로 연다) · 별 칸(단추 밖의 빈자리 — 아무 일도 없다) */
const ROW_NO_OPEN = 'a, .mail-table__star'
const drag = useMailRowDrag()
const who = computed(() => rowWho(props.message, { role: props.role, mixed: props.mixed, view: 'table' }))
@@ -61,16 +71,24 @@ const held = computed(() => isHeldMessage(props.message))
/** 검색 강조 (14단계) — "(제목 없음)"에는 없다 */
const subjectRanges = computed(() => (props.message.subject ? (props.message.searchHighlight?.subject ?? null) : null))
/** 행 어디를 눌러도 연다 — 버튼을 누른 경우 · 제목 링크(스스로 연다) · 글자를 고르는 중이면 그대로 둔다 */
/**
* 행 어디를 눌러도 연다 — 단추(고르기 · 별)를 누른 경우 · 제목 링크(스스로 연다) · 별 칸의 빈자리 · 글자를 고르는 중이면 그대로 둔다.
* 고르기 칸의 빈자리(단추 밖)는 고르기 상자를 누른 것과 같다 — 고르고(Shift 는 범위) 열지 않는다, 고를 수 없는 줄이면 아무 일도 없다.
* 무엇을 눌렀는지는 사건이 지나간 길(eventPathHas)로 가른다 — 고르기 상자 가운데를 누르면 target 은 상자 그림인데 단추의 처리기가
* 고르기를 바꾸며 다시 그려 문서에서 떨어진다(target.closest 로 보다가 메일까지 열렸다 — 2026-11-10 사용자 보고)
*/
function onRowClick(event: MouseEvent) {
const target = event.target instanceof Element ? event.target : null
if (target?.closest('button')) return
if (eventPathHas(event, ROW_CONTROLS)) return
if (eventPathHas(event, CHECK_CELL)) {
if (!held.value) emit('check', props.message.id, event.shiftKey)
return
}
if (event.ctrlKey || event.metaKey || event.shiftKey) {
event.preventDefault()
if (!held.value) emit('check', props.message.id, event.shiftKey)
return
}
if (target?.closest('a')) return
if (eventPathHas(event, ROW_NO_OPEN)) return
if (window.getSelection()?.toString()) return
emit('open', props.to)
}
@@ -216,6 +234,11 @@ function onMouseDown(event: MouseEvent) {
color: var(--color-star);
}
/* 별 그림은 누름을 받지 않는다 — 누른 것(target)이 늘 단추다 (고르기 상자 그림과 같다 — MailCheckbox) */
.mail-table__star-button .app-icon {
pointer-events: none;
}
.mail-table__who {
font-weight: 450;
color: var(--color-text-strong);
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { shallowRef } from 'vue'
@@ -626,3 +626,104 @@ describe('RecipientField', () => {
})
})
})
describe('RecipientField — 칩이 세 줄을 넘으면 칸 안에서 스크롤 (2026-11-10 사용자 보고)', () => {
// 전 직원을 숨은참조에 넣자 칸이 늘어 쓰기 화면이 넘치고 [보내기]가 화면 밖으로 숨었다 — 칩 · 입력 칸(.recipient-field__control)은
// 높이 한도(--size-recipient-chips-max)에서 그 칸 안이 스크롤한다(값은 ComposePage.scroll.spec 이 원본 style 에서 본다).
// jsdom 은 배치를 하지 않으므로 칸의 높이를 정해 주고 scrollTop 을 붙잡는다
const MANY: RecipientToken[] = Array.from({ length: 30 }, (_, i) =>
tokenFromAddress({ name: '', email: `notice-staff${i + 1}@example.com` }),
)
/** 칩 · 입력 칸의 scrollTop — 칸의 내용 높이는 400 */
function scrollOf(wrapper: VueWrapper) {
const control = wrapper.get('.recipient-field__control').element
Object.defineProperty(control, 'scrollHeight', { configurable: true, value: 400 })
const box = { top: 0 }
Object.defineProperty(control, 'scrollTop', {
configurable: true,
get: () => box.top,
set: (value: number) => {
box.top = value
},
})
return box
}
it('초점이 오면 칸을 맨 아래로 — 입력칸은 늘 칸의 끝에 있어 입력 자리가 보인다', async () => {
const wrapper = mountField(MANY)
const scroll = scrollOf(wrapper)
await wrapper.get('input').trigger('focus')
expect(scroll.top).toBe(400)
})
it('칩이 늘면(확정) 그린 뒤 맨 아래로 · 줄면(빈 입력칸 Backspace) 칸의 자리를 그대로 둔다', async () => {
const wrapper = mountField(MANY)
const scroll = scrollOf(wrapper)
const input = wrapper.get('input')
await input.setValue('new.one@example.com')
scroll.top = 0
await input.trigger('keydown', { key: 'Enter' })
await flushPromises()
expect(tokensOf(wrapper)).toHaveLength(31)
expect(scroll.top).toBe(400)
scroll.top = 120
await input.trigger('keydown', { key: 'Backspace' })
await flushPromises()
expect(tokensOf(wrapper)).toHaveLength(30)
expect(scroll.top).toBe(120)
})
it('글을 치면 맨 아래로 — 칸을 위로 올려 본 채 쳐도 치는 자리가 보인다 (한글 조합 중 글자도)', async () => {
const wrapper = mountField(MANY)
const scroll = scrollOf(wrapper)
const input = wrapper.get('input')
await input.setValue('kim')
expect(scroll.top).toBe(400)
scroll.top = 0
await input.trigger('compositionupdate')
expect(scroll.top).toBe(400)
})
it('가운데 칩의 ✕ 는 그 칩만 지우고 초점은 지금처럼 입력칸으로 — 칸의 스크롤은 그대로 둔다(이어 지우게)', async () => {
const wrapper = mountField(MANY, suggestionsOf([]))
const scroll = scrollOf(wrapper)
const input = wrapper.get('input').element as HTMLInputElement
const focusSpy = vi.spyOn(input, 'focus')
scroll.top = 120
await wrapper.get('button[aria-label="notice-staff5@example.com 삭제"]').trigger('click')
expect(tokensOf(wrapper)).toHaveLength(29)
expect(tokensOf(wrapper).some((token) => token.address.email === 'notice-staff5@example.com')).toBe(false)
expect(focusSpy).toHaveBeenCalledWith({ preventScroll: true })
expect(document.activeElement).toBe(input)
expect(scroll.top).toBe(120)
// 다시 들어오면(Tab · 누름) 맨 아래로
input.blur()
input.focus()
expect(scroll.top).toBe(400)
wrapper.unmount()
})
it('추천 목록은 칩 · 입력 칸 밖(줄 아래)에 뜬다 — 칸이 스크롤해도 목록이 잘리지 않게', async () => {
const suggestions = suggestionsOf([{ name: '이도윤', email: 'doyun.lee@arasystem.it.kr', source: 'directory' }])
const wrapper = mountField(MANY, suggestions)
const input = wrapper.get('input')
;(input.element as HTMLInputElement).focus()
await input.trigger('focus')
await input.setValue('도윤')
const list = wrapper.get('.recipient-suggestions')
expect(list.element.closest('.recipient-field')).toBe(wrapper.get('.recipient-field').element)
expect(list.element.closest('.recipient-field__control')).toBeNull()
wrapper.unmount()
})
})
@@ -16,6 +16,10 @@
// input · compositionupdate 의 값을 따로 둔다(query). 주소 토큰 확정은 v-model(draft) 그대로다
// - 한글 조합 중의 Enter · Tab 은 글자 확정이다 — 토큰으로도 추천으로도 넣지 않는다 (isImeComposing — Safari 의 keyCode 229 포함)
// 목록은 RecipientSuggestionList 가 그린다 — 고르기 · 넣기 키는 이 입력칸이 받는다(초점은 늘 입력칸).
// 칩이 세 줄을 넘으면 칩 · 입력 칸(.recipient-field__control) 안에서 스크롤한다 (--size-recipient-chips-max — 2026-11-10 사용자 보고:
// 전 직원을 숨은참조에 넣자 칸이 늘어 쓰기 화면이 넘쳤다). 추천 목록은 그 칸 밖(줄 아래)에 뜬다.
// 초점이 오거나 · 칩이 늘거나 · 글을 치면 칸을 맨 아래로(입력 자리가 보이게). 칩의 ✕ 로 지우면 초점은 지금처럼 입력칸으로 가되
// 칸의 스크롤은 그대로 둔다 — 가운데 칩을 이어 지울 때 자리가 맨 아래로 튀지 않게
// 고정 id — 목록 #{inputId}-suggestions · 추천 #{inputId}-option-{n} · 안내 #{inputId}-suggestions-hint (recipient-suggestion-ids.ts)
// 사용 예: <RecipientField v-model="to" label="받는 사람" input-id="compose-to" error-id="compose-recipient-error" />
import { computed, inject, nextTick, onBeforeUnmount, ref, watch } from 'vue'
@@ -79,6 +83,10 @@ const draft = ref<string>('')
/** 추천 검색어 — 입력칸에 보이는 글 그대로(한글 조합 중인 글자 포함). 조합이 끝나면 draft 와 같아진다 */
const query = ref<string>('')
const inputRef = ref<HTMLInputElement | null>(null)
/** 칩 · 입력 칸 — 칩이 세 줄을 넘으면 이 칸 안에서 스크롤한다 */
const controlRef = ref<HTMLElement | null>(null)
/** 칩을 지우며 입력칸에 초점을 줄 때 — 칸의 스크롤을 그대로 둔다 (onFocus 가 맨 아래로 옮기지 않게) */
let keepScrollOnFocus = false
/** 입력칸에 초점이 있다 — 그동안만 추천 목록을 연다 */
const focused = ref<boolean>(false)
/** Esc 로 닫았다 — 다시 치면 연다 */
@@ -260,13 +268,24 @@ function onPaste(event: ClipboardEvent) {
commit(`${draft.value}${text}`)
}
/** 입력칸에 보이는 글 — 한글 조합 중인 글자도 (v-model 은 조합 중의 input 을 건너뛴다) */
/**
* 칩 · 입력 칸을 맨 아래로 — 입력칸은 늘 칸의 끝에 있으므로 칩이 세 줄을 넘어 칸 안에서 스크롤할 때 입력 자리가 보인다.
* 넘치지 않으면 아무 일도 없다
*/
function revealInput() {
const control = controlRef.value
if (control) control.scrollTop = control.scrollHeight
}
/** 입력칸에 보이는 글 — 한글 조합 중인 글자도 (v-model 은 조합 중의 input 을 건너뛴다). 치는 자리가 보이게 칸을 맨 아래로 */
function onTextInput(event: Event) {
query.value = (event.target as HTMLInputElement).value
revealInput()
}
function onFocus() {
focused.value = true
if (!keepScrollOnFocus) revealInput()
// 미리 받기가 실패했으면 다시 받아 본다 (받았거나 받는 중이면 아무것도 하지 않는다)
suggestions?.preload()
}
@@ -282,7 +301,10 @@ function remove(token: RecipientToken) {
'update:modelValue',
props.modelValue.filter((item) => recipientTokenKey(item) !== key),
)
inputRef.value?.focus()
// 초점은 지금처럼 입력칸으로 — 칩이 많아 칸 안에서 스크롤하는 중이면 그 자리를 그대로 둔다 (가운데 칩을 이어 지우게)
keepScrollOnFocus = true
inputRef.value?.focus({ preventScroll: true })
keepScrollOnFocus = false
}
function focus() {
@@ -321,6 +343,15 @@ watch(query, (text) => {
suggestions?.search(text)
})
// 칩이 늘면(확정 · 추천 · 그룹 · 붙여 넣기 · 저장본) 그린 뒤 칸을 맨 아래로 — 입력 자리가 보이게. 줄 때(지우기)는 그대로 둔다
watch(
() => props.modelValue.length,
(count, previous) => {
if (count > previous) revealInput()
},
{ flush: 'post' },
)
// 추천이 늘거나 줄면(서버 결과 · 이미 넣은 주소) 고른 자리를 목록 안에 둔다
watch(
() => options.value.length,
@@ -344,7 +375,10 @@ defineExpose({ commit, focus, clearDraft, hasDraft })
:for="inputId"
class="recipient-field__label"
>{{ label }}</label>
<div class="recipient-field__control">
<div
ref="controlRef"
class="recipient-field__control"
>
<span
v-for="token in modelValue"
:key="recipientTokenKey(token)"
@@ -444,13 +478,23 @@ defineExpose({ commit, focus, clearDraft, hasDraft })
color: var(--color-text-secondary);
}
/*
* 칩 · 입력 — 칩이 세 줄을 넘으면 이 칸 안에서 스크롤한다 (--size-recipient-chips-max 96 + 위 여백 4 = 100 · 2026-11-10 사용자 보고).
* 위 · 좌우에 안쪽 여백 4 를 두고 바깥 여백을 같은 만큼 당긴다 — 자리 · 폭 · 줄바꿈은 그대로이고, 칸 가장자리 칩의 ✕ 초점 고리(바깥 4)가
* 잘리지 않는다. 아래는 당기지 않는다 — 입력이 든 마지막 줄(34)은 칩(28)보다 높아 고리가 들고, 줄의 아래 선(초점 선)을 덮지 않게
*/
.recipient-field__control {
flex: 1 1 20rem;
flex: 1 1 calc(20rem + var(--space-4) * 2);
min-width: 0;
max-height: calc(var(--size-recipient-chips-max) + var(--space-4));
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-4) var(--space-6);
margin: calc(var(--space-4) * -1) calc(var(--space-4) * -1) 0;
padding: var(--space-4) var(--space-4) 0;
overflow-x: hidden;
overflow-y: auto;
}
.recipient-field__input {
@@ -222,6 +222,22 @@ describe('EditorToolbar (본문 서식 툴바)', () => {
expect(document.activeElement?.getAttribute('data-tool')).toBe('more')
})
it('메뉴 · 팝오버가 열린 동안만 툴바를 쓰기 화면 아래에 붙은 [보내기] 줄(z-index 3) 위로 올린다 — editor-toolbar--popup-open (2026-11-10)', async () => {
const view = mountEditor()
const toolbar = view.get('.editor-toolbar')
expect(toolbar.classes()).not.toContain('editor-toolbar--popup-open')
await tool(view, 'more').trigger('click')
expect(toolbar.classes()).toContain('editor-toolbar--popup-open')
await view.get('[role="menu"][aria-label="서식 더보기"]').trigger('keydown', { key: 'Escape' })
await nextTick()
expect(toolbar.classes()).not.toContain('editor-toolbar--popup-open')
await tool(view, 'textColor').trigger('click')
expect(toolbar.classes()).toContain('editor-toolbar--popup-open')
})
it('마우스로 열면 메뉴 자체에 초점(고른 항목에 키보드 고리가 뜨지 않게) — 아래 화살표로 항목에 들어간다. 키보드로 열면 켜진 항목에', async () => {
const view = mountEditor()
editorOf(view.element).commands.focus()
@@ -341,7 +341,7 @@ defineExpose({
<template>
<div
ref="rootRef"
class="editor-toolbar"
:class="['editor-toolbar', { 'editor-toolbar--popup-open': popup !== null }]"
role="toolbar"
aria-label="본문 서식"
:aria-controls="MAIL_EDITOR_BODY_ID"
@@ -676,6 +676,14 @@ defineExpose({
background: var(--color-surface);
}
/*
* 메뉴 · 팝오버를 연 동안은 쓰기 화면 아래에 붙은 단추 줄(z-index 3 — ComposeFooter) 위에 — 툴바 아래로 연 메뉴가 그 줄에 가리지 않게.
* 닫으면 2 로 돌아가 툴바가 그 줄 밑으로 지나간다 (2026-11-10)
*/
.editor-toolbar--popup-open {
z-index: 4;
}
.editor-toolbar__group {
flex: 0 0 auto;
display: flex;
@@ -367,6 +367,16 @@ defineExpose({
overflow-y: auto;
}
/*
* 넓은 화면(70rem 이상)에서는 쓰기 화면이 화면 높이에 묶여 따로 스크롤한다 — 자리가 모자라면 편집 영역이 200 까지 먼저 줄어
* 쓰기 칸 전체가 화면 안에 든다(바깥 스크롤 + 본문 스크롤의 이중 스크롤을 막는다). 자리가 넉넉하면 지금처럼 늘어난다 (ComposePage · 2026-11-10)
*/
@media (min-width: 70rem) {
.mail-editor__frame {
min-height: var(--size-editor-body-min-fit);
}
}
/* 끌어다 놓는 동안의 덮개 — 2px 점선(포커스 선은 실선)이라 모양이 다르다 */
.mail-editor__drop {
position: absolute;
@@ -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,216 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createManualClock, type ManualClock } from '@/test-utils/manual-clock'
import { useFocusAboveFooter } from './useFocusAboveFooter'
// 아래에 붙은 [보내기] 줄 밑에 가린 칸으로 초점이 오면 가린 만큼 올린다 (2026-11-10 — 좁은 화면에서 Tab 으로 간 [파일 첨부]가 그 줄에 가렸다).
// jsdom 은 배치를 하지 않는다 — 상자 크기를 정해 주고, 스크롤 상자의 scrollTop 을 잡아 본다
function rectOf(top: number, bottom: number): DOMRect {
return { top, bottom, height: bottom - top, left: 0, right: 0, width: 0, x: 0, y: top, toJSON: () => ({}) } as DOMRect
}
/** scrollTop 을 담는 칸 — jsdom 은 스크롤하지 않으므로 값만 붙잡는다 */
function trackScroll(element: Element, start = 0): { value: number } {
const box = { value: start }
Object.defineProperty(element, 'scrollTop', {
configurable: true,
get: () => box.value,
set: (value: number) => {
box.value = value
},
})
return box
}
describe('useFocusAboveFooter — [보내기] 줄 밑에 초점이 가리지 않게', () => {
let clock: ManualClock
let frames: (() => void)[]
let scroller: HTMLElement
let row: HTMLElement
let input: HTMLInputElement
let attach: HTMLButtonElement
let footer: HTMLElement
let send: HTMLButtonElement
let rects: Map<Element, DOMRect>
beforeEach(() => {
clock = createManualClock(10_000)
frames = []
// 넓은 화면 — 쓰기 칸이 스크롤 상자다 (overflow-y: auto · 넘친다)
scroller = document.createElement('main')
scroller.style.overflowY = 'auto'
Object.defineProperty(scroller, 'scrollHeight', { configurable: true, value: 1200 })
Object.defineProperty(scroller, 'clientHeight', { configurable: true, value: 600 })
row = document.createElement('div')
row.className = 'compose-field'
input = document.createElement('input')
row.append(input)
attach = document.createElement('button')
footer = document.createElement('div')
send = document.createElement('button')
footer.append(send)
scroller.append(row, attach, footer)
document.body.append(scroller)
rects = new Map<Element, DOMRect>([
[scroller, rectOf(56, 656)],
[footer, rectOf(591, 656)],
[row, rectOf(560, 604)],
[input, rectOf(565, 599)],
[attach, rectOf(600, 644)],
[send, rectOf(601, 645)],
])
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) {
return rects.get(this) ?? rectOf(0, 0)
})
})
afterEach(() => {
vi.restoreAllMocks()
document.body.innerHTML = ''
})
function setup() {
return useFocusAboveFooter({ footer: () => footer, clock, nextFrame: (callback) => frames.push(callback) })
}
/** 초점을 옮기고 그 focusin 을 넘긴다 (재는 것은 다음 그림 — frames) */
function focusIn(handler: ReturnType<typeof setup>, element: HTMLElement) {
element.focus()
const event = new FocusEvent('focusin', { bubbles: true })
Object.defineProperty(event, 'target', { value: element })
handler.onFocusIn(event)
}
function pointerDown(handler: ReturnType<typeof setup>, element: Element) {
const event = new Event('pointerdown', { bubbles: true }) as PointerEvent
Object.defineProperty(event, 'target', { value: element })
handler.onPointerDown(event)
}
function runFrames() {
for (const frame of frames.splice(0)) frame()
}
it('키보드로 간 칸이 그 줄에 가렸으면 가린 만큼(+ 8) 쓰기 칸을 올린다 — 줄 전체(.compose-field)를 드러낸다', () => {
const scroll = trackScroll(scroller, 100)
const handler = setup()
focusIn(handler, input)
// 그 자리에서 재지 않는다 — 브라우저가 초점에 맞춰 스크롤한 다음 그림에
expect(scroll.value).toBe(100)
runFrames()
// 줄 아래 604 + 8 − 그 줄의 위 591 = 21
expect(scroll.value).toBe(121)
})
it('줄 밖의 요소([파일 첨부])는 그 요소를 드러낸다', () => {
const scroll = trackScroll(scroller, 0)
const handler = setup()
focusIn(handler, attach)
runFrames()
expect(scroll.value).toBe(644 + 8 - 591)
})
it('가리지 않았으면 · 아래 줄 안의 단추면 그대로 둔다', () => {
const scroll = trackScroll(scroller, 0)
const handler = setup()
rects.set(attach, rectOf(500, 544))
focusIn(handler, attach)
focusIn(handler, send)
runFrames()
expect(scroll.value).toBe(0)
})
it('마우스 · 손가락으로 누른 칸은 그대로 둔다 — 누른 채 내용이 움직이면 글이 골라진다. 1초가 지난 뒤의 키보드 초점은 올린다', () => {
const scroll = trackScroll(scroller, 0)
const handler = setup()
pointerDown(handler, input)
focusIn(handler, input)
runFrames()
expect(scroll.value).toBe(0)
clock.advance(1000)
attach.focus()
focusIn(handler, input)
runFrames()
expect(scroll.value).toBe(21)
})
it('다른 곳을 누른 뒤 코드가 옮긴 초점(보내기 전 검사 → 받는 사람 칸 등)은 올린다', () => {
const scroll = trackScroll(scroller, 0)
const handler = setup()
pointerDown(handler, send)
focusIn(handler, input)
runFrames()
expect(scroll.value).toBe(21)
})
it('칸이 보이는 자리보다 크면 칸의 위를 맞춘다 — 위가 화면 밖으로 나가지 않게', () => {
const scroll = trackScroll(scroller, 0)
const handler = setup()
// 쓰기 칸 위 56 · 그 줄 위 591 — 보이는 자리는 535. 칸 300..900
rects.set(row, rectOf(300, 900))
focusIn(handler, input)
runFrames()
// 가린 만큼은 900 + 8 − 591 = 317 이지만 칸의 위가 56 + 8 까지만 올라간다 — 300 − 56 − 8 = 236
expect(scroll.value).toBe(236)
})
it('그린 차례 전에 초점이 다른 곳으로 갔으면 올리지 않는다', () => {
const scroll = trackScroll(scroller, 0)
const handler = setup()
focusIn(handler, input)
send.focus()
runFrames()
expect(scroll.value).toBe(0)
})
it('쓰기 칸이 스크롤하지 않으면(좁은 화면) 문서를 올린다', () => {
Object.defineProperty(scroller, 'scrollHeight', { configurable: true, value: 600 })
const documentScroller = document.scrollingElement ?? document.documentElement
const scroll = trackScroll(documentScroller, 40)
const handler = setup()
try {
focusIn(handler, attach)
runFrames()
expect(scroll.value).toBe(40 + 644 + 8 - 591)
} finally {
// 문서의 scrollTop 은 다른 시험에 남기지 않는다
Reflect.deleteProperty(documentScroller, 'scrollTop')
}
})
it('쓰기 칸에 단 처리기는 안에서 올라온 focusin · pointerdown 을 받는다 (사건의 target 으로 가른다)', () => {
const scroll = trackScroll(scroller, 0)
const handler = setup()
scroller.addEventListener('focusin', (event) => handler.onFocusIn(event))
scroller.addEventListener('pointerdown', (event) => handler.onPointerDown(event as PointerEvent), { capture: true })
// 누른 [파일 첨부]는 그대로 (focus 가 focusin 을 올려 보낸다)
attach.dispatchEvent(new Event('pointerdown', { bubbles: true }))
attach.focus()
runFrames()
expect(scroll.value).toBe(0)
// 1초 뒤 Tab 으로 온 초점은 올린다 — 그사이 지나간 칸(input)은 초점이 떠나 올리지 않는다
clock.advance(1000)
input.focus()
attach.focus()
runFrames()
expect(scroll.value).toBe(644 + 8 - 591)
})
})
@@ -0,0 +1,79 @@
import type { Clock } from '@/composables/useClock'
// 아래에 붙은 줄(position: sticky — 쓰기 화면의 [보내기] 줄) 밑에 초점이 가리지 않게 한다 (WCAG 2.4.11 — 2026-11-10 사용자 보고의 고침과 함께).
// 브라우저는 초점을 준 요소가 스크롤 상자 안에 있으면 스크롤하지 않는다 — 그 줄 밑에 들어간 요소(좁은 화면에서 Tab 으로 간 [파일 첨부] 등)는
// 가린 채 남는다. 키보드 · 코드(오류 칸으로 옮기는 초점 등)로 초점이 들어오면, 브라우저가 스스로 스크롤한 다음 그림에 재어 가린 만큼 올린다.
// 마우스 · 손가락으로 누른 요소는 보이는 곳을 누른 것이므로 그대로 둔다 — 누른 채 내용이 움직이면 글이 골라진다.
// 드러내는 것은 쓰기 칸의 줄 전체(.compose-field — 받는 사람 줄 · 제목 줄 · 본문 편집 영역)이고, 줄이 보이는 자리보다 크면 줄의 위를 맞춘다.
// 쓰는 곳: 쓰기 화면의 form 에 @focusin · @pointerdown.capture (ComposePage)
/** 드러낸 요소와 아래 줄 사이 · 화면 위와의 틈 (--space-8) */
const FOCUS_GAP_PX = 8
/** 누른 뒤 이 안에 누른 줄로 온 초점은 누른 것으로 본다 — 손가락은 뗄 때 초점이 온다 */
const POINTER_FOCUS_WINDOW_MS = 1000
export interface FocusAboveFooterOptions {
/** 아래에 붙은 줄 */
footer: () => HTMLElement | null
/** 누른 때를 잰다 — 시험은 손으로 돌리는 시계 */
clock: Clock
/** 다음 그림 차례에 부른다 — 기본은 requestAnimationFrame (브라우저가 초점에 맞춰 스크롤한 뒤) */
nextFrame?: (callback: () => void) => void
}
/** 드러낼 칸 — 쓰기 칸의 줄(.compose-field)이면 줄 전체, 아니면 그 요소 */
function boxOf(target: Element): Element {
return target.closest('.compose-field') ?? target
}
/** 아래 줄이 붙어 있는 스크롤 상자 — 넓은 화면은 쓰기 칸, 좁은 화면은 문서 */
function scrollerOf(element: HTMLElement): Element {
for (let node = element.parentElement; node; node = node.parentElement) {
const { overflowY } = getComputedStyle(node)
if ((overflowY === 'auto' || overflowY === 'scroll') && node.scrollHeight > node.clientHeight) return node
}
return document.scrollingElement ?? document.documentElement
}
export function useFocusAboveFooter(options: FocusAboveFooterOptions) {
const nextFrame = options.nextFrame ?? ((callback: () => void) => void requestAnimationFrame(() => callback()))
/** 마지막으로 누른 곳과 때 */
let pressed: { target: Node; at: number } | null = null
function onPointerDown(event: PointerEvent) {
if (event.target instanceof Node) pressed = { target: event.target, at: options.clock.now() }
}
/** 방금 이 칸 안을 눌렀다 — 그 누름으로 온 초점이다 */
function pressedInside(box: Element): boolean {
return (
pressed !== null && options.clock.now() - pressed.at < POINTER_FOCUS_WINDOW_MS && box.contains(pressed.target)
)
}
/** 가린 만큼 올린다 — 칸의 위가 보이는 자리 위로 나가지 않을 만큼까지 */
function reveal(target: Element, box: Element, footer: HTMLElement) {
// 그사이 초점이 다른 곳으로 갔거나 화면을 떠났다
if (document.activeElement !== target || !footer.isConnected) return
const rect = box.getBoundingClientRect()
const hidden = rect.bottom + FOCUS_GAP_PX - footer.getBoundingClientRect().top
if (hidden <= 0) return
const scroller = scrollerOf(footer)
const viewTop = scroller === document.scrollingElement ? 0 : scroller.getBoundingClientRect().top
const delta = Math.min(hidden, rect.top - viewTop - FOCUS_GAP_PX)
if (delta > 0) scroller.scrollTop += delta
}
function onFocusIn(event: FocusEvent) {
const target = event.target
if (!(target instanceof Element)) return
const footer = options.footer()
// 아래 줄 안의 단추는 늘 보인다
if (!footer || footer.contains(target)) return
const box = boxOf(target)
if (pressedInside(box)) return
nextFrame(() => reveal(target, box, footer))
}
return { onFocusIn, onPointerDown }
}
@@ -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,224 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import footerSource from '@/components/mail/ComposeFooter.vue?raw'
import recipientSource from '@/components/mail/RecipientField.vue?raw'
import suggestionListSource from '@/components/mail/RecipientSuggestionList.vue?raw'
import editorSource from '@/components/mail/editor/MailEditor.vue?raw'
import toolbarSource from '@/components/mail/editor/EditorToolbar.vue?raw'
import { CLOCK } from '@/composables/useClock'
import { useAuthStore } from '@/stores/auth.store'
import { createManualClock, type ManualClock } from '@/test-utils/manual-clock'
import { flushPromises, installMockApi, ok, restoreApiAdapter } from '@/test-utils/mock-api'
import { installProseMirrorDom } from '@/test-utils/prosemirror-dom'
import { createTestRouter, routeViewProvide } from '@/test-utils/router'
import ComposePage from './ComposePage.vue'
import composeSource from './ComposePage.vue?raw'
// 쓰기 화면 스크롤 (2026-11-10 사용자 보고 — 전 직원을 숨은참조에 넣자 받는 사람 칸이 늘어 쓰기 칸이 넘쳤고, 바깥 스크롤 + 본문 스크롤의
// 이중 스크롤 속에 맨 아래 [보내기] 줄이 화면 밖으로 숨었다. 본문 위에서 휠을 돌리면 본문만 움직였다).
// - [보내기] 줄은 스크롤하는 칸의 아래에 붙는다(sticky) · 받는 사람 칸은 칩 세 줄을 넘으면 칸 안이 스크롤 · 넓은 화면은 본문 편집 영역이 200 까지 줄어든다
// - 겹침 차례: 툴바 2 < [보내기] 줄 3 < 추천 목록 · 메뉴를 연 툴바 4
// jsdom 은 크기를 재지 않는다 — 배치 값은 원본의 style 을 읽고(ContactTransferDialogs.layout.spec 과 같은 읽기), 초점 올리기는 상자 크기를 정해 준다.
// 토큰 값(tokens.css — 받는 사람 칸 96 · 넓은 화면 편집 영역 200)은 시험에서 읽지 못한다(vitest 는 CSS 파일을 비운다 — ?raw 도) — 컴포넌트가 그 토큰을
// 쓰는지만 보고, 실제 폭 · 높이(1440×900 · 1366×768 · 1000×600 · 360×800)는 브라우저로 확인했다 (디자인 README "메일 쓰기 › 받는 사람이 많을 때")
/** 원본 style 의 CSS — 주석은 뺀다 */
function cssOf(source: string): string {
const start = source.indexOf('<style')
const css = start < 0 ? source : source.slice(source.indexOf('>', start) + 1, source.lastIndexOf('</style>'))
return css.replace(/\/\*[\s\S]*?\*\//g, '')
}
/** { 로 여는 블록의 안쪽 — open 은 그 { 의 자리 */
function blockAt(css: string, open: number): { inner: string; end: number } {
let depth = 0
for (let i = open; i < css.length; i += 1) {
if (css[i] === '{') depth += 1
else if (css[i] === '}') {
depth -= 1
if (depth === 0) return { inner: css.slice(open + 1, i), end: i }
}
}
return { inner: '', end: css.length }
}
/** 미디어 쿼리 블록을 뺀 맨 위 단계의 CSS */
function topLevel(css: string): string {
let out = ''
let from = 0
for (let at = css.indexOf('@media', from); at >= 0; at = css.indexOf('@media', from)) {
out += css.slice(from, at)
from = blockAt(css, css.indexOf('{', at)).end + 1
}
return out + css.slice(from)
}
/** 한 규칙의 본문 — media 를 주면 그 미디어 쿼리 안에서, 주지 않으면 맨 위 단계에서 */
function ruleBody(source: string, selector: string, media?: string): string {
const css = cssOf(source)
let scope = topLevel(css)
if (media) {
const at = css.indexOf(`@media ${media}`)
scope = at < 0 ? '' : blockAt(css, css.indexOf('{', at)).inner
}
for (const [, found = '', body = ''] of scope.matchAll(/([^{}]+)\{([^}]*)\}/g)) {
if (found.trim() === selector) return body
}
return ''
}
const WIDE = '(min-width: 70rem)'
describe('쓰기 화면 스크롤 — 원본 style (2026-11-10 사용자 보고)', () => {
it('[보내기] 줄은 스크롤하는 칸의 아래에 붙는다 — sticky · bottom 0 · 흰 바탕, 위 선 · 여백은 그대로', () => {
const footer = ruleBody(footerSource, '.compose__footer')
expect(footer).toMatch(/position:\s*sticky;/)
expect(footer).toMatch(/bottom:\s*0;/)
expect(footer).toMatch(/z-index:\s*3;/)
expect(footer).toMatch(/background:\s*var\(--color-surface\);/)
expect(footer).toMatch(/border-top:\s*var\(--border-width\) solid var\(--color-border\);/)
expect(footer).toMatch(/padding:\s*var\(--space-10\) var\(--content-inline\);/)
})
it('겹침 차례 — 툴바 2 < [보내기] 줄 3 < 추천 목록 · 메뉴를 연 툴바 4 (툴바는 줄 밑으로 지나가고, 툴바의 메뉴와 추천 목록은 줄 위에 뜬다)', () => {
const z = (source: string, selector: string) => Number(ruleBody(source, selector).match(/z-index:\s*(\d+);/)?.[1])
expect(z(toolbarSource, '.editor-toolbar')).toBe(2)
expect(z(footerSource, '.compose__footer')).toBe(3)
expect(z(toolbarSource, '.editor-toolbar--popup-open')).toBe(4)
expect(z(suggestionListSource, '.recipient-suggestions')).toBe(4)
})
it('받는 사람 · 참조 · 숨은참조 — 칩 · 입력 칸은 96(+ 위 여백 4)을 넘으면 그 칸 안에서 스크롤한다', () => {
const control = ruleBody(recipientSource, '.recipient-field__control')
expect(control).toMatch(/max-height:\s*calc\(var\(--size-recipient-chips-max\) \+ var\(--space-4\)\);/)
expect(control).toMatch(/overflow-y:\s*auto;/)
expect(control).toMatch(/overflow-x:\s*hidden;/)
// 안쪽 여백 4(위 · 좌우)를 바깥 여백으로 당긴다 — 자리 · 폭 · 줄바꿈은 그대로
expect(control).toMatch(/margin:\s*calc\(var\(--space-4\) \* -1\) calc\(var\(--space-4\) \* -1\) 0;/)
expect(control).toMatch(/padding:\s*var\(--space-4\) var\(--space-4\) 0;/)
expect(control).toMatch(/flex:\s*1 1 calc\(20rem \+ var\(--space-4\) \* 2\);/)
})
it('넓은 화면은 본문 편집 영역이 300 에서 200 까지 먼저 줄어 쓰기 칸 전체가 화면 안에 든다 — 좁은 화면은 300 그대로', () => {
expect(ruleBody(editorSource, '.mail-editor__frame')).toMatch(/min-height:\s*var\(--size-editor-body-min\);/)
expect(ruleBody(editorSource, '.mail-editor__frame', WIDE)).toMatch(/min-height:\s*var\(--size-editor-body-min-fit\);/)
expect(ruleBody(composeSource, '.compose__editor')).toMatch(
/min-height:\s*calc\(var\(--size-editor-toolbar\) \+ var\(--size-editor-body-min\) \+ var\(--border-width\)\);/,
)
expect(ruleBody(composeSource, '.compose__editor', WIDE)).toMatch(
/min-height:\s*calc\(var\(--size-editor-toolbar\) \+ var\(--size-editor-body-min-fit\) \+ var\(--border-width\)\);/,
)
expect(ruleBody(composeSource, '.compose', WIDE)).toMatch(/overflow-y:\s*auto;/)
})
it('쓰기 칸은 위치를 잡는다 — 안의 화면 낭독 전용 요소(.visually-hidden)가 앱 틀을 넘겨 문서까지 스크롤하지 않게', () => {
expect(ruleBody(composeSource, '.compose')).toMatch(/position:\s*relative;/)
})
})
describe('쓰기 화면 — [보내기] 줄 밑에 가린 칸으로 온 초점은 올린다 (WCAG 2.4.11)', () => {
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
/** 지연 로딩한 편집기를 기다리는 시간 — ComposePage.spec 과 같다 (테스트 한도 20초보다 짧게) */
const EDITOR_LOAD_TIMEOUT_MS = 15_000
let wrapper: VueWrapper | null = null
let pinia: Pinia
let clock: ManualClock
let frames: FrameRequestCallback[]
beforeAll(installProseMirrorDom)
beforeAll(async () => {
await import('@/components/mail/editor/MailEditor.vue')
}, EDITOR_LOAD_TIMEOUT_MS)
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated(USER)
clock = createManualClock(1_000_000)
frames = []
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
frames.push(callback)
return frames.length
})
})
afterEach(() => {
wrapper?.unmount()
wrapper = null
vi.restoreAllMocks()
restoreApiAdapter()
Reflect.deleteProperty(document.scrollingElement ?? document.documentElement, 'scrollTop')
document.body.innerHTML = ''
})
async function mountCompose() {
installMockApi(() => ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } }))
const router = createTestRouter()
await router.push('/mail/compose')
await router.isReady()
wrapper = mount(ComposePage, {
attachTo: document.body,
global: { plugins: [pinia, router], provide: { ...routeViewProvide(router), [CLOCK as symbol]: clock } },
})
await flushPromises()
await vi.waitFor(
() => {
if (!wrapper?.find('.ProseMirror').exists()) throw new Error('편집기를 불러오는 중')
},
{ timeout: EDITOR_LOAD_TIMEOUT_MS },
)
await flushPromises()
return wrapper
}
function rectOf(top: number, bottom: number): DOMRect {
return { top, bottom, height: bottom - top, left: 0, right: 0, width: 0, x: 0, y: top, toJSON: () => ({}) } as DOMRect
}
function runFrames() {
for (const frame of frames.splice(0)) frame(0)
}
it('좁은 화면(문서가 스크롤) — Tab 으로 간 제목 줄이 [보내기] 줄에 가렸으면 가린 만큼(+ 8) 문서를 올리고, 누른 칸은 그대로 둔다', async () => {
const page = await mountCompose()
const subject = page.get('#compose-subject').element as HTMLInputElement
const row = subject.closest('.compose-field')
const footer = page.get('.compose__footer').element
// [보내기] 줄 위 500 · 제목 줄 470~514 — 줄 아래 14px 이 가렸다
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) {
if (this === footer) return rectOf(500, 565)
if (this === row) return rectOf(470, 514)
return rectOf(0, 0)
})
const documentScroller = document.scrollingElement ?? document.documentElement
const scroll = { top: 300 }
Object.defineProperty(documentScroller, 'scrollTop', {
configurable: true,
get: () => scroll.top,
set: (value: number) => {
scroll.top = value
},
})
runFrames()
// 누른 칸은 그대로 — 보이는 곳을 누른 것이다 (누른 채 내용이 움직이면 글이 골라진다)
subject.dispatchEvent(new Event('pointerdown', { bubbles: true }))
subject.focus()
runFrames()
expect(scroll.top).toBe(300)
// 1초 뒤 키보드로 다시 오면 올린다 — 514 + 8 − 500 = 22
clock.advance(1000)
subject.blur()
subject.focus()
runFrames()
expect(scroll.top).toBe(322)
// [보내기] 줄 안의 단추는 늘 보인다 — 올리지 않는다
;(page.get('.compose__send').element as HTMLButtonElement).focus()
runFrames()
expect(scroll.top).toBe(322)
})
})
+38 -2
View File
@@ -23,6 +23,11 @@
// 그렇게 맞추지 못한 옛 주소(방문 기록의 ?draft=<옛 id>)로 돌아오면 이 탭이 마지막으로 저장한 저장본을 연다 (useComposeDraftLink).
// - 휴지통 · 되돌리기는 useDraftTrash, 머리 · 아래 단추 줄 · 칸 안내 띠는 ComposeHeader · ComposeFooter · ComposeAlert.
// 받는 사람 자동 완성: 열 때 추천을 한 번 받아 두고 세 칸이 함께 쓴다 (useRecipientSuggestions → RecipientField).
// 스크롤 (2026-11-10 사용자 보고 — 받는 사람이 많으면 바깥 · 본문 이중 스크롤에 [보내기] 줄이 화면 밖으로 숨었다):
// - [보내기] 줄은 스크롤하는 칸(넓은 화면은 이 쓰기 칸, 좁은 화면은 문서)의 아래에 붙어 늘 보인다 (ComposeFooter — sticky)
// - 받는 사람 · 참조 · 숨은참조는 칩이 세 줄을 넘으면 그 칸 안에서 스크롤한다 (RecipientField)
// - 넓은 화면은 본문 편집 영역이 300 에서 200 까지 먼저 줄어 보통 높이의 화면에서는 쓰기 칸 전체가 화면 안에 든다 (아래 style · MailEditor)
// - 그 줄에 가린 칸으로 키보드 · 코드의 초점이 오면 가린 만큼 올린다 (useFocusAboveFooter)
// 본문은 서식 편집기(TipTap) — 이 화면에서만 불러온다(지연 로딩). 보낼 때 서식 본문(bodyHtml)과 같은 내용의 텍스트(body)를 함께 보낸다.
// 편집기 청크를 못 받으면 본문 자리에 사전 문구(SYS_001)와 "다시 시도"를 보인다 (ComposeEditorLoadError).
// 본문에 붙여 넣거나 끌어다 놓은 파일(이미지 포함)은 첨부로 올린다 — 본문의 그림은 서명 그림뿐이다 (사용자 확정).
@@ -91,6 +96,7 @@ import { useComposeDraftLink } from '@/composables/useComposeDraftLink'
import { useComposeLargeFiles } from '@/composables/useComposeLargeFiles'
import { useDraftSaving, type DraftSaveRejected, type DraftSaveRequest } from '@/composables/useDraftSaving'
import { useDraftTrash } from '@/composables/useDraftTrash'
import { useFocusAboveFooter } from '@/composables/useFocusAboveFooter'
import { useLeaveConfirm } from '@/composables/useLeaveConfirm'
import { useMail } from '@/composables/useMail'
import { RECIPIENT_SUGGESTIONS, useRecipientSuggestions } from '@/composables/useRecipientSuggestions'
@@ -325,6 +331,19 @@ const subjectInputRef = ref<HTMLInputElement | null>(null)
const fileInputRef = ref<HTMLInputElement | null>(null)
const attachButtonRef = ref<HTMLButtonElement | null>(null)
const attachmentListRef = ref<InstanceType<typeof ComposeAttachmentGroups> | null>(null)
const footerRef = ref<InstanceType<typeof ComposeFooter> | null>(null)
/**
* 아래에 붙은 [보내기] 줄 밑에 초점이 가리지 않게 (2026-11-10) — 키보드 · 코드로 초점이 들어온 칸이 그 줄에 가렸으면 가린 만큼 올린다.
* 줄은 넘쳐도 늘 보이도록 아래에 붙어 있다 (ComposeFooter · useFocusAboveFooter)
*/
const { onFocusIn: revealFocused, onPointerDown: notePointerDown } = useFocusAboveFooter({
footer: () => {
const element: unknown = footerRef.value?.$el
return element instanceof HTMLElement ? element : null
},
clock,
})
function queryId(value: unknown): string | null {
return typeof value === 'string' && value !== '' ? value : null
@@ -1265,6 +1284,8 @@ watch(
aria-labelledby="compose-title"
novalidate
@submit.prevent="onSubmit"
@focusin="revealFocused"
@pointerdown.capture="notePointerDown"
>
<ComposeHeader
:title="title"
@@ -1423,6 +1444,7 @@ watch(
</div>
<ComposeFooter
ref="footerRef"
:can-send="canSend"
:blocked-reason="sendBlock?.text ?? null"
:sending="sending"
@@ -1466,7 +1488,12 @@ watch(
</template>
<style scoped>
/*
* 위치를 잡은 칸 — 안의 화면 낭독 전용 요소(.visually-hidden — 파일 고르기 칸 등)가 이 칸 안에 든다.
* 잡지 않으면 화면 전체가 기준이 되어 넓은 화면에서 앱 틀(100vh)이 넘치고 문서까지 스크롤했다 (2026-11-10 — 이중 스크롤의 한 갈래)
*/
.compose {
position: relative;
flex: 999 1 35rem;
min-width: 0;
display: flex;
@@ -1525,7 +1552,7 @@ watch(
}
/*
* 본문 서식 편집기 자리 — 툴바(52) + 편집 영역(300) + 아래 선.
* 본문 서식 편집기 자리 — 툴바(52) + 편집 영역(300 — 넓은 화면에서 자리가 모자라면 200 까지, 아래 미디어 쿼리) + 아래 선.
* 편집기를 내려받는 동안에도 같은 높이를 둔다. 포커스는 편집 영역의 아래 선이다 (.compose-field, styles/base.css).
*/
.compose__editor {
@@ -1574,10 +1601,19 @@ watch(
color: var(--color-text-muted);
}
/* 넓은 화면에서는 쓰기 칸만 따로 스크롤한다 (tokens.css 화면 폭 기준) */
/*
* 넓은 화면에서는 쓰기 칸만 따로 스크롤한다 (tokens.css 화면 폭 기준).
* 쓰기 칸이 화면보다 길어지려 하면 본문 편집 영역이 300 에서 200(--size-editor-body-min-fit)까지 먼저 줄어든다 — 받는 사람이 많아도
* 보통 높이의 화면에서는 쓰기 칸 전체가 화면 안에 들고 본문만 스크롤한다(편집 영역의 같은 값은 MailEditor). 그래도 넘치면 쓰기 칸이 스크롤하고
* [보내기] 줄은 아래에 붙어 있다 (ComposeFooter — 2026-11-10 사용자 보고)
*/
@media (min-width: 70rem) {
.compose {
overflow-y: auto;
}
.compose__editor {
min-height: calc(var(--size-editor-toolbar) + var(--size-editor-body-min-fit) + var(--border-width));
}
}
</style>
@@ -0,0 +1,375 @@
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { createRouter, createWebHistory, type Router } from 'vue-router'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import type { ReleaseNote, ReleaseNoteSummary } from '@/shared/types/release-notes'
import { useAuthStore } from '@/stores/auth.store'
import { useToastStore } from '@/stores/toast.store'
import {
fail,
flushPromises,
installMockApi,
ok,
restoreApiAdapter,
type MockRequest,
type MockResult,
} from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
import InfoReleaseNotePage from './InfoReleaseNotePage.vue'
// 설정 › 정보 › 업데이트 내역 — 글 (디자인 원본 ReleaseNotes A2 · README "업데이트 내역 › 글")
// 맨 위 글 · 가운데 글(다른 글 줄) · 없는 글(404 RES_001 — 알림 없이 그 화면) · 받지 못함 — 어떤 상태에서도 위에 [← 목록으로] 줄
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
const LIST_PATH = '/settings/info/release-notes'
const TOP_SUMMARY: ReleaseNoteSummary = {
id: '2026-11-18',
date: '2026-11-18',
title: '메일 검색이 빨라지고 휴대폰 앱이 나왔습니다',
}
const MID_SUMMARY: ReleaseNoteSummary = { id: '2026-11-05', date: '2026-11-05', title: '화면의 안내 글을 다듬었습니다' }
const MENU_SUMMARY: ReleaseNoteSummary = { id: '2026-11-04', date: '2026-11-04', title: '왼쪽 메뉴를 정리했습니다' }
/** 맨 위 글 — 다음 글 없음 · 이전 글은 가운데 글 */
const TOP: ReleaseNote = {
...TOP_SUMMARY,
sections: [
{
heading: '휴대폰 앱',
items: [
'안드로이드 휴대폰 앱이 나왔습니다. 웹메일의 [설정] → [휴대폰 앱]에서 받을 수 있습니다.',
'앱에서 메일을 읽고 쓰고 찾을 수 있고, 새 메일이 오면 휴대폰으로 알려 드립니다. 알림에는 새 메일이 몇 통인지만 나오고, 보낸 사람이나 제목은 나오지 않습니다.',
],
},
{ heading: '메일 검색', items: ['찾은 말에는 노란 표시가 붙습니다.'] },
],
previous: MID_SUMMARY,
next: null,
}
/** 가운데 글 — 머리 없는 묶음 · 다음 글(맨 위 글) · 이전 글 */
const MID: ReleaseNote = {
...MID_SUMMARY,
sections: [
{
heading: null,
items: [
'단추와 안내 글을 더 알기 쉬운 말로 고쳤습니다.',
'브라우저 탭과 휴대폰 홈 화면의 아이콘을 ARA SYSTEM 마크로 바꿨습니다.',
],
},
],
previous: MENU_SUMMARY,
next: TOP_SUMMARY,
}
const NOTES: Record<string, ReleaseNote> = { [TOP.id]: TOP, [MID.id]: MID }
function noteReply(request: MockRequest): MockResult {
const id = decodeURIComponent(request.url.replace('/release-notes/', ''))
const note = NOTES[id]
return note ? ok(note) : fail(404, 'RES_001')
}
/** 응답을 손으로 보내는 요청 — 받는 동안의 화면을 본다 */
function deferredReply() {
let resolve: (result: MockResult) => void = () => {}
const promise = new Promise<MockResult>((settle) => {
resolve = settle
})
return { promise, resolve }
}
/**
* 좁은 화면(문서가 스크롤)을 꾸민다 — 문서를 scrolled 만큼 내린 채, 문서 맨 위에서 잰 글 칸(main)의 위 끝과 머리(head)의 아래 끝.
* jsdom 은 배치를 재지 못한다 — getBoundingClientRect 를 꾸민다(창 높이는 768)
*/
function narrowLayout(scrolled: number, mainTop: number, headBottom: number, head: string) {
document.documentElement.scrollTop = scrolled
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) {
if (this.matches('main')) return { top: mainTop - scrolled, bottom: 0 } as DOMRect
if (this.matches(head)) return { top: 0, bottom: headBottom - scrolled } as DOMRect
return { top: 0, bottom: 0 } as DOMRect
})
}
describe('InfoReleaseNotePage', () => {
let pinia: Pinia
let requests: MockRequest[]
let scrollTo: MockInstance<typeof window.scrollTo>
const mounted: VueWrapper[] = []
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated(USER)
scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => undefined)
})
afterEach(() => {
for (const wrapper of mounted.splice(0)) {
if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
}
vi.restoreAllMocks()
useToastStore().clear()
restoreApiAdapter()
document.documentElement.scrollTop = 0
document.body.innerHTML = ''
})
async function mountAt(
id: string,
reply: (request: MockRequest) => MockResult | Promise<MockResult> = noteReply,
router: Router = createTestRouter(),
) {
requests = installMockApi(reply)
await router.push(LIST_PATH)
await router.push(`${LIST_PATH}/${id}`)
await router.isReady()
const wrapper = mount(InfoReleaseNotePage, { attachTo: document.body, global: { plugins: [pinia, router] } })
mounted.push(wrapper)
await flushPromises()
return { wrapper, router }
}
const requestLines = () => requests.map((request) => `${request.method} ${request.url}`)
const neighborRows = (wrapper: VueWrapper) =>
wrapper.findAll('nav[aria-label="다른 글"] a').map((row) => ({
kind: row.attributes('data-release-note-neighbor'),
text: row.findAll('span, time').map((cell) => cell.text()),
href: row.attributes('href'),
}))
describe('글 (A2)', () => {
it('맨 위 글 — 날짜 · 제목(h1) · 묶음(머리 + 점 목록), 위에 [← 목록으로] 줄 · 끝에 [목록]', async () => {
const { wrapper } = await mountAt(TOP.id)
const main = wrapper.get('main')
const title = wrapper.get('h1')
expect(title.text()).toBe('메일 검색이 빨라지고 휴대폰 앱이 나왔습니다')
expect(main.attributes('aria-labelledby')).toBe(title.attributes('id'))
expect(main.attributes('aria-label')).toBeUndefined()
const date = wrapper.get('article time')
expect(date.text()).toBe('2026년 11월 18일')
expect(date.attributes('datetime')).toBe('2026-11-18')
const sections = wrapper.findAll('[data-release-note-section]')
expect(sections.map((section) => section.find('h2').text())).toEqual(['휴대폰 앱', '메일 검색'])
expect(sections[0]?.findAll('li').map((item) => item.text())).toEqual(TOP.sections[0]?.items)
const back = wrapper.get('a[data-release-note-back]')
expect(back.text()).toBe('목록으로')
expect(back.attributes('href')).toBe(LIST_PATH)
expect(back.classes()).toContain('tool-button--accent')
const list = wrapper.get('a[data-release-note-list]')
expect(list.text()).toBe('목록')
expect(list.attributes('href')).toBe(LIST_PATH)
expect(requestLines()).toEqual(['GET /release-notes/2026-11-18'])
expect(requests[0]?.config.silent).toBe(true)
})
it('맨 위 글이라 다음 글 줄은 없고 이전 글 줄만 — 줄 전체가 그 글로 가는 링크', async () => {
const { wrapper } = await mountAt(TOP.id)
expect(neighborRows(wrapper)).toEqual([
{
kind: 'older',
text: ['이전 글', '화면의 안내 글을 다듬었습니다', '2026년 11월 5일'],
href: `${LIST_PATH}/2026-11-05`,
},
])
})
it('가운데 글 — 다음 글(더 새 글)이 위 · 이전 글(더 옛 글)이 아래, 머리 없는 묶음은 점 목록만', async () => {
const { wrapper } = await mountAt(MID.id)
expect(neighborRows(wrapper)).toEqual([
{
kind: 'newer',
text: ['다음 글', '메일 검색이 빨라지고 휴대폰 앱이 나왔습니다', '2026년 11월 18일'],
href: `${LIST_PATH}/2026-11-18`,
},
{
kind: 'older',
text: ['이전 글', '왼쪽 메뉴를 정리했습니다', '2026년 11월 4일'],
href: `${LIST_PATH}/2026-11-04`,
},
])
const section = wrapper.get('[data-release-note-section]')
expect(section.find('h2').exists()).toBe(false)
expect(section.findAll('li')).toHaveLength(2)
})
it('글이 하나뿐이면 다른 글 칸째 없다 · [목록]은 그대로', async () => {
const { wrapper } = await mountAt(TOP.id, () => ok({ ...TOP, previous: null, next: null }))
expect(wrapper.find('nav[aria-label="다른 글"]').exists()).toBe(false)
expect(wrapper.find('a[data-release-note-list]').exists()).toBe(true)
})
it('다른 글 줄을 누르면 기록을 바꿔 끼우고 그 글을 받는다 — 누른 줄이 사라져 잃은 초점은 새 제목으로', async () => {
const { wrapper, router } = await mountAt(MID.id)
const newer = wrapper.get('a[data-release-note-neighbor="newer"]')
;(newer.element as HTMLElement).focus()
await newer.trigger('click', { button: 0 })
await flushPromises()
expect(router.currentRoute.value.params.id).toBe(TOP.id)
expect(requestLines()).toEqual(['GET /release-notes/2026-11-05', 'GET /release-notes/2026-11-18'])
const title = wrapper.get('h1')
expect(title.text()).toBe(TOP.title)
expect(document.activeElement).toBe(title.element)
// 바꿔 끼웠으니 뒤로 가기는 목록으로 간다
router.back()
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe(LIST_PATH)
})
})
describe('목록으로', () => {
it('[← 목록으로] · [목록] — 바로 앞 기록이 목록이 아니면 목록으로 바꿔 끼운다', async () => {
const { wrapper, router } = await mountAt(TOP.id)
await wrapper.get('a[data-release-note-list]').trigger('click', { button: 0 })
await flushPromises()
expect(router.currentRoute.value.name).toBe('settings-info-release-notes')
})
it('바로 앞 기록이 목록이면 뒤로 가기를 쓴다 (기록을 쌓지 않는다)', async () => {
// 메모리 history 는 history.state 를 쓰지 않으므로 실제 브라우저 history 로 확인한다
const router = createRouter({ history: createWebHistory(), routes: createTestRouter().options.routes })
const { wrapper } = await mountAt(TOP.id, noteReply, router)
await wrapper.get('a[data-release-note-back]').trigger('click', { button: 0 })
await vi.waitFor(() => expect(router.currentRoute.value.fullPath).toBe(LIST_PATH))
// 뒤로 간 것이라 앞으로 갈 기록(보던 글)이 남아 있다 — 바꿔 끼웠다면 없다
expect(router.options.history.state.forward).toBe(`${LIST_PATH}/${TOP.id}`)
})
})
describe('상태', () => {
it('받는 동안은 글 자리를 막대로 — 위의 [← 목록으로] 줄은 그대로', async () => {
const reply = deferredReply()
const { wrapper } = await mountAt(TOP.id, () => reply.promise)
const placeholder = wrapper.get('[data-release-note-loading]')
expect(placeholder.attributes('aria-busy')).toBe('true')
expect(placeholder.attributes('aria-label')).toBe('글 불러오는 중')
expect(wrapper.find('a[data-release-note-back]').exists()).toBe(true)
expect(wrapper.get('main').attributes('aria-label')).toBe('업데이트 내역')
reply.resolve(ok(TOP))
await flushPromises()
expect(wrapper.find('[data-release-note-loading]').exists()).toBe(false)
expect(wrapper.get('h1').text()).toBe(TOP.title)
})
it('없는 글(404 RES_001) — 알림 없이 사전 문구와 [목록으로], 위 줄은 그대로', async () => {
const { wrapper, router } = await mountAt('2026-01-01')
const error = wrapper.get('[data-release-note-error]')
expect(error.attributes('role')).toBe('alert')
expect(error.text()).toContain(ERROR_CODE_MESSAGES.RES_001)
expect(error.text()).toContain('문의 코드 3f1c0a2e')
expect(wrapper.find('a[data-release-note-back]').exists()).toBe(true)
expect(wrapper.find('article').exists()).toBe(false)
expect(useToastStore().toasts).toEqual([])
const leave = error.get('button')
expect(leave.text()).toBe('목록으로')
await leave.trigger('click')
await flushPromises()
expect(router.currentRoute.value.name).toBe('settings-info-release-notes')
})
it('글 id 꼴이 아닌 주소는 묻지 않고 없는 글 화면', async () => {
const { wrapper } = await mountAt('latest')
expect(requests).toEqual([])
const error = wrapper.get('[data-release-note-error]')
expect(error.text()).toContain(ERROR_CODE_MESSAGES.RES_001)
expect(error.get('button').text()).toBe('목록으로')
})
it('받지 못함 — 알림 없이 사전 문구 · [다시 시도] · 문의 코드, [다시 시도]는 같은 글을 다시 받는다', async () => {
let attempt = 0
const { wrapper } = await mountAt(TOP.id, () => {
attempt += 1
return attempt === 1 ? fail(502, 'SYS_001') : ok(TOP)
})
const error = wrapper.get('[data-release-note-error]')
expect(error.text()).toContain(ERROR_CODE_MESSAGES.SYS_001)
expect(error.text()).toContain('문의 코드 3f1c0a2e')
expect(useToastStore().toasts).toEqual([])
await error.get('button').trigger('click')
await flushPromises()
expect(requestLines()).toEqual(['GET /release-notes/2026-11-18', 'GET /release-notes/2026-11-18'])
expect(wrapper.get('h1').text()).toBe(TOP.title)
})
})
// 2026-11-10 실사용 시험 — 목록을 내린 채 글을 열면 앞 화면의 스크롤 자리를 이어받아 날짜 · 제목이 위로 지나가 있었다
describe('글의 맨 위가 보이게', () => {
it('받은 뒤에 맞춘다 — 문서를 맨 위로 올려도 제목이 보이면 맨 위로 (메뉴가 옆에 있는 폭 1000×600)', async () => {
narrowLayout(174, 57, 178, 'h1')
const reply = deferredReply()
await mountAt(TOP.id, () => reply.promise)
expect(scrollTo).not.toHaveBeenCalled()
reply.resolve(ok(TOP))
await flushPromises()
expect(scrollTo.mock.calls).toEqual([[{ top: 0, behavior: 'instant' }]])
})
it('위에 메뉴가 길어 맨 위로는 제목이 보이지 않으면 글 칸의 위 끝을 화면 위 끝에 (휴대폰 360×800)', async () => {
narrowLayout(1007, 906, 1027, 'h1')
await mountAt(TOP.id)
expect(scrollTo.mock.calls).toEqual([[{ top: 906, behavior: 'instant' }]])
})
it('없는 글이어도 그 안내가 보이게 맞춘다', async () => {
narrowLayout(1007, 906, 1240, '[data-release-note-error]')
const { wrapper } = await mountAt('2026-01-01')
expect(wrapper.find('[data-release-note-error]').exists()).toBe(true)
expect(scrollTo.mock.calls).toEqual([[{ top: 906, behavior: 'instant' }]])
})
it('문서가 맨 위에 있으면(처음 연 화면) 문서는 그대로 둔다', async () => {
await mountAt(TOP.id)
expect(scrollTo).not.toHaveBeenCalled()
})
it('다른 글로 옮기면 따로 스크롤하는 글 칸(넓은 화면)도 맨 위로 — 새 제목에는 스크롤 없이 초점만', async () => {
const { wrapper } = await mountAt(MID.id)
const main = wrapper.get('main').element as HTMLElement
main.scrollTop = 700
const focus = vi.spyOn(HTMLElement.prototype, 'focus')
const newer = wrapper.get('a[data-release-note-neighbor="newer"]')
;(newer.element as HTMLElement).focus()
await newer.trigger('click', { button: 0 })
await flushPromises()
const title = wrapper.get('h1')
expect(title.text()).toBe(TOP.title)
expect(main.scrollTop).toBe(0)
expect(document.activeElement).toBe(title.element)
const titleFocus = focus.mock.calls.filter((_, index) => focus.mock.contexts[index] === title.element)
expect(titleFocus).toEqual([[{ preventScroll: true }]])
})
})
})
@@ -0,0 +1,274 @@
<script setup lang="ts">
// 설정 › 정보 › 업데이트 내역 — 글 (디자인 원본 ReleaseNotes A2 · A3 · README "업데이트 내역") — /settings/info/release-notes/{id} · 탭 제목 "업데이트 내역"
// id 는 글 파일 이름(예 2026-11-18). 글을 보는 동안에도 설정 메뉴의 업데이트 내역이 고른 상태다(라우트를 목록 아래에 묶었다 — routes.ts)
// - 위: [← 목록으로] 줄(52 — 목록 보기에서 연 메일의 줄과 같은 꼴 · 스크롤해도 위에 남는다). 어떤 상태에서도 둔다
// - 글: 날짜 · 제목(h1) · 묶음(ReleaseNoteArticle) → 다른 글 줄(ReleaseNoteNeighbors — 없는 쪽은 줄이 없고, 둘 다 없으면 칸째 없다) → [목록](44)
// - 받는 동안은 글 자리를 막대로. 없는 글(404 RES_001 · 글 id 꼴이 아닌 주소): 사전 문구 + [목록으로], 그 밖의 실패: 사전 문구 + [다시 시도] — 알림 없음
// - [← 목록으로] · [목록] · 없는 글의 [목록으로]는 바로 앞 기록이 목록이면 뒤로 가기, 아니면 목록으로 바꿔 끼운다(기록을 쌓지 않는다 — 메일의 목록으로와 같다).
// 새 탭 · 새 창으로 여는 누름(Ctrl · ⌘ · Shift · 가운데 단추)은 브라우저에 맡긴다
// - 글을 열거나 다른 글로 옮기면(같은 화면이 주소만 바뀐다) 받은 뒤 글의 맨 위(날짜 · 제목)가 보이게 스크롤을 맞춘다 — 앞 화면의 스크롤 자리를 이어받지 않게
// (revealSettingsPageTop — 넓은 화면은 글 칸, 좁은 화면은 문서). 다른 글로 옮겨 누른 줄이 사라져 초점을 잃었으면 새 제목으로 옮긴다
// - 위에 붙은 [← 목록으로] 줄 밑에 초점이 가리지 않게 글 칸의 제목 · 링크 · 단추는 줄 높이만큼 띄워 스크롤한다(scroll-margin-top)
import { computed, nextTick, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import MailLoadError from '@/components/mail/MailLoadError.vue'
import MailToolButton from '@/components/mail/MailToolButton.vue'
import ReleaseNoteArticle from '@/components/settings/ReleaseNoteArticle.vue'
import ReleaseNoteNeighbors from '@/components/settings/ReleaseNoteNeighbors.vue'
import { useMailNavigation } from '@/composables/useMailNavigation'
import { revealSettingsPageTop } from '@/composables/useSettingsPageScroll'
import { RELEASE_NOTES_TEXT } from '@/shared/constants/release-notes'
import { SETTINGS_ROUTES } from '@/shared/constants/settings-menu'
import { releaseNoteNeighbors } from '@/shared/utils/release-notes'
import { useReleaseNotesStore } from '@/stores/release-notes.store'
const TITLE_ID = 'release-note-title'
const LIST_LOCATION = { name: SETTINGS_ROUTES.releaseNotes }
const route = useRoute()
const router = useRouter()
const releaseNotesStore = useReleaseNotesStore()
const { backOrReplace } = useMailNavigation()
const mainRef = ref<HTMLElement | null>(null)
const articleRef = ref<InstanceType<typeof ReleaseNoteArticle> | null>(null)
/** 주소의 글 id — 이 화면의 라우트일 때만(떠나는 사이 바뀐 주소로 다시 받지 않게) */
const noteId = computed<string | null>(() => {
const id = route.params.id
return route.name === SETTINGS_ROUTES.releaseNote && typeof id === 'string' ? id : null
})
const listHref = computed(() => router.resolve(LIST_LOCATION).href)
const note = computed(() => (releaseNotesStore.noteStatus === 'ready' ? releaseNotesStore.note : null))
const neighbors = computed(() => (note.value ? releaseNoteNeighbors(note.value) : []))
const failure = computed(() =>
releaseNotesStore.noteStatus === 'missing' || releaseNotesStore.noteStatus === 'error'
? releaseNotesStore.noteError
: null,
)
/** 누른 줄 · 단추가 사라져 초점을 잃었는지 */
function focusLost(): boolean {
const active = document.activeElement
return active === null || active === document.body || !active.isConnected
}
function backToList() {
void backOrReplace(LIST_LOCATION)
}
/** 목록으로 가는 링크 — 새 탭 · 새 창으로 여는 누름은 브라우저에 맡긴다 */
function onListLink(event: MouseEvent) {
if (event.ctrlKey || event.metaKey || event.shiftKey || event.altKey || event.button !== 0) return
event.preventDefault()
backToList()
}
function retry() {
if (noteId.value !== null) void releaseNotesStore.loadNote(noteId.value)
}
/**
* 글을 받고 그린 뒤 글의 맨 위가 보이게 맞춘다(없는 글 · 받지 못함이면 그 안내가 보이게).
* 다른 글에서 옮겨 왔고(moved) 누른 줄이 사라져 초점을 잃었으면 새 제목으로 — 스크롤은 위에서 맞췄으니 초점만 옮긴다
*/
async function open(id: string, moved: boolean) {
await releaseNotesStore.loadNote(id)
// 그사이 다른 글로 옮겼으면 그 글이 맞춘다 · 화면을 떠났으면 할 일이 없다
if (noteId.value !== id) return
await nextTick()
const main = mainRef.value
if (!main) return
revealSettingsPageTop(main, main.querySelector<HTMLElement>('h1, [data-release-note-error]') ?? main)
if (moved && releaseNotesStore.noteStatus === 'ready' && focusLost()) articleRef.value?.focusTitle()
}
// 처음 그리기 전에 받기 시작한다 — 지난번에 받아 둔 글을 잠깐 그렸다 바꾸지 않게. 같은 화면에서 주소의 id 만 바뀌어도 다시 받는다
watch(
noteId,
(id, previous) => {
if (id !== null) void open(id, typeof previous === 'string')
},
{ immediate: true },
)
</script>
<template>
<main
ref="mainRef"
class="release-note"
:aria-labelledby="note ? TITLE_ID : undefined"
:aria-label="note ? undefined : RELEASE_NOTES_TEXT.title"
>
<div class="release-note__bar">
<div class="release-note__bar-row">
<MailToolButton
icon="arrow-left"
:label="RELEASE_NOTES_TEXT.backToList"
tone="accent"
:href="listHref"
:icon-stroke-width="1.9"
data-release-note-back
@click="onListLink"
/>
</div>
</div>
<div class="release-note__column">
<MailLoadError
v-if="failure"
:error="failure"
:leave-label="RELEASE_NOTES_TEXT.backToList"
data-release-note-error
@retry="retry"
@leave="backToList"
/>
<template v-else-if="note">
<ReleaseNoteArticle
ref="articleRef"
:note="note"
:title-id="TITLE_ID"
/>
<ReleaseNoteNeighbors
v-if="neighbors.length > 0"
:neighbors="neighbors"
/>
<div class="release-note__actions">
<a
:href="listHref"
class="release-note__list-link"
data-release-note-list
@click="onListLink"
>{{ RELEASE_NOTES_TEXT.list }}</a>
</div>
</template>
<div
v-else
class="release-note__placeholder"
aria-busy="true"
:aria-label="RELEASE_NOTES_TEXT.noteLoading"
data-release-note-loading
>
<span class="release-note__placeholder-bar release-note__placeholder-bar--date" />
<span class="release-note__placeholder-bar release-note__placeholder-bar--title" />
<span class="release-note__placeholder-bar release-note__placeholder-bar--heading" />
<span class="release-note__placeholder-bar" />
<span class="release-note__placeholder-bar release-note__placeholder-bar--short" />
</div>
</div>
</main>
</template>
<style scoped>
/* 설정 화면의 틀(SettingsPageFrame)과 같은 칸 — 위에 [← 목록으로] 줄이 하나 더 있다 */
.release-note {
flex: 999 1 35rem;
min-width: 0;
display: flex;
flex-direction: column;
word-break: keep-all;
overflow-wrap: anywhere;
}
/* [← 목록으로] 줄 — 칸 전체 폭으로 아래 선을 긋고, 스크롤해도 위에 남는다 (읽기 화면의 도구 줄과 같다) */
.release-note__bar {
position: sticky;
top: 0;
z-index: 1;
border-bottom: var(--border-width) solid var(--color-border);
background: var(--color-surface);
}
/* 콘텐츠 열 안 — 안쪽 22 + 단추 안쪽 10 = 아이콘이 32 선 */
.release-note__bar-row {
display: flex;
align-items: center;
max-width: var(--content-max-width);
min-height: var(--size-header-min);
padding: var(--space-4) var(--content-inline) var(--space-4) var(--content-toolbar-start);
}
.release-note__column {
max-width: var(--content-max-width);
display: flex;
flex-direction: column;
gap: var(--space-22);
padding: var(--content-block-start) var(--content-inline) var(--content-block-end);
}
/*
* 위에 붙은 [← 목록으로] 줄(52 + 선 1) 밑에 초점이 가리지 않게 (WCAG 2.4.11) — 초점을 옮기며 스크롤할 때 줄 높이 + 8 만큼 띄운다.
* 스크롤하는 것이 글 칸이든 문서든 같다
*/
.release-note__column :deep(:is(h1, a, button, [tabindex])) {
scroll-margin-top: calc(var(--size-header-min) + var(--border-width) + var(--space-8));
}
.release-note__actions {
display: flex;
}
/* [목록] 44 — 흰 바탕 · 테두리 */
.release-note__list-link {
display: inline-flex;
align-items: center;
min-height: var(--size-touch);
padding: 0 var(--space-18);
border: var(--border-width) solid var(--color-border-strong);
border-radius: var(--radius-md);
background: var(--color-surface);
color: var(--color-text-strong);
font-weight: 600;
text-decoration: none;
}
.release-note__list-link:hover {
background: var(--color-surface-muted);
color: var(--color-text-strong);
}
/* 받는 동안 — 날짜 · 제목 · 묶음 자리 (목록의 막대와 같은 결) */
.release-note__placeholder {
display: flex;
flex-direction: column;
gap: var(--space-12);
}
.release-note__placeholder-bar {
width: 80%;
max-width: var(--content-measure);
height: var(--space-10);
border-radius: var(--radius-xs);
background: var(--color-border-subtle);
}
.release-note__placeholder-bar--date {
width: 7.5rem;
background: var(--color-skeleton);
}
.release-note__placeholder-bar--title {
width: 55%;
height: var(--space-18);
margin-bottom: var(--space-12);
background: var(--color-skeleton);
}
.release-note__placeholder-bar--heading {
width: 20%;
height: var(--space-12);
background: var(--color-skeleton);
}
.release-note__placeholder-bar--short {
width: 45%;
}
/* 넓은 화면에서는 이 칸만 따로 스크롤한다 (설정 화면의 틀과 같다) */
@media (min-width: 70rem) {
.release-note {
overflow-y: auto;
}
}
</style>
@@ -0,0 +1,284 @@
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import type { Router } from 'vue-router'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import type { ReleaseNoteSummary } from '@/shared/types/release-notes'
import { useAuthStore } from '@/stores/auth.store'
import { useToastStore } from '@/stores/toast.store'
import {
fail,
flushPromises,
installMockApi,
ok,
restoreApiAdapter,
type MockRequest,
type MockResult,
} from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
import InfoReleaseNotesPage from './InfoReleaseNotesPage.vue'
// 설정 › 정보 › 업데이트 내역 — 목록 (디자인 원본 ReleaseNotes A1 · README "업데이트 내역 › 목록")
// 글이 있을 때(머리 줄 · 줄 전체 링크 · [더 보기]) · 불러오는 중(막대) · 받지 못함(사전 문구 · [다시 시도] · 문의 코드) · 글이 없을 때
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
/** 보드의 넷 — 맨 위 글은 다음 배포의 첫 글 초안 */
const BOARD_NOTES: ReleaseNoteSummary[] = [
{ id: '2026-11-18', date: '2026-11-18', title: '메일 검색이 빨라지고 휴대폰 앱이 나왔습니다' },
{ id: '2026-11-05', date: '2026-11-05', title: '화면의 안내 글을 다듬었습니다' },
{ id: '2026-11-04', date: '2026-11-04', title: '왼쪽 메뉴를 정리했습니다' },
{ id: '2026-11-03', date: '2026-11-03', title: '2단계 인증과 앱 비밀번호를 쓸 수 있습니다' },
]
/** 20개를 넘는 목록 — 첫 쪽 20 · 둘째 쪽 2 */
const MANY = Array.from({ length: 22 }, (_, index): ReleaseNoteSummary => {
const id = `2026-10-${String(31 - index).padStart(2, '0')}`
return { id, date: id, title: `${31 - index}일의 업데이트` }
})
const lastPage = (items: ReleaseNoteSummary[]) => ({ items, pageInfo: { hasNext: false, nextCursor: null, total: items.length } })
/** 응답을 손으로 보내는 요청 — 받는 동안의 화면을 본다 */
function deferredReply() {
let resolve: (result: MockResult) => void = () => {}
const promise = new Promise<MockResult>((settle) => {
resolve = settle
})
return { promise, resolve }
}
describe('InfoReleaseNotesPage', () => {
let pinia: Pinia
let requests: MockRequest[]
let router: Router
let scrollTo: MockInstance<typeof window.scrollTo>
const mounted: VueWrapper[] = []
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated(USER)
scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => undefined)
})
afterEach(() => {
for (const wrapper of mounted.splice(0)) {
if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
}
vi.restoreAllMocks()
useToastStore().clear()
restoreApiAdapter()
document.documentElement.scrollTop = 0
document.body.innerHTML = ''
})
async function mountPage(reply: (request: MockRequest) => MockResult | Promise<MockResult>) {
requests = installMockApi(reply)
router = createTestRouter()
await router.push('/settings/info/release-notes')
await router.isReady()
const wrapper = mount(InfoReleaseNotesPage, { attachTo: document.body, global: { plugins: [pinia, router] } })
mounted.push(wrapper)
await flushPromises()
return wrapper
}
const requestLines = () => requests.map((request) => `${request.method} ${request.url}`)
describe('글이 있을 때 (A1)', () => {
it('머리 · 설명 한 줄 — 목록은 머리 줄(제목 · 날짜) 아래 최근 글부터 한 줄씩, 날짜는 "2026년 11월 18일"', async () => {
const wrapper = await mountPage(() => ok(lastPage(BOARD_NOTES)))
expect(wrapper.get('h1').text()).toBe('업데이트 내역')
expect(wrapper.text()).toContain('웹메일과 휴대폰 앱에 새로 생기거나 바뀐 것을 알려 드립니다.')
const head = wrapper.get('.release-note-list__head')
expect(head.attributes('aria-hidden')).toBe('true')
expect(head.findAll('span').map((cell) => cell.text())).toEqual(['제목', '날짜'])
const rows = wrapper.findAll('a[data-release-note-row]')
expect(rows.map((row) => row.get('[data-release-note-title]').text())).toEqual(BOARD_NOTES.map((note) => note.title))
expect(rows.map((row) => row.get('[data-release-note-date]').text())).toEqual([
'2026년 11월 18일',
'2026년 11월 5일',
'2026년 11월 4일',
'2026년 11월 3일',
])
expect(rows[0]?.get('time').attributes('datetime')).toBe('2026-11-18')
expect(requestLines()).toEqual(['GET /release-notes'])
expect(requests[0]?.params).toEqual({ limit: 20 })
})
it('목록 이름은 화면 제목이고, 줄 전체가 그 글로 가는 링크다', async () => {
const wrapper = await mountPage(() => ok(lastPage(BOARD_NOTES)))
const list = wrapper.get('ul[role="list"]')
expect(list.attributes('aria-labelledby')).toBe(wrapper.get('h1').attributes('id'))
const first = list.get('a[data-release-note-row]')
expect(first.attributes('href')).toBe('/settings/info/release-notes/2026-11-18')
await first.trigger('click', { button: 0 })
await flushPromises()
expect(router.currentRoute.value.name).toBe('settings-info-release-note')
expect(router.currentRoute.value.params.id).toBe('2026-11-18')
})
it('20개뿐이면 [더 보기]가 없다', async () => {
const wrapper = await mountPage(() => ok(lastPage(MANY.slice(0, 20))))
expect(wrapper.findAll('a[data-release-note-row]')).toHaveLength(20)
expect(wrapper.find('[data-release-notes-more]').exists()).toBe(false)
})
})
describe('[더 보기]', () => {
function pagedReply(second: () => MockResult | Promise<MockResult>) {
return (request: MockRequest) =>
request.params.cursor === 'c20'
? second()
: ok({ items: MANY.slice(0, 20), pageInfo: { hasNext: true, nextCursor: 'c20', total: 22 } })
}
it('20개씩 이어 받는다 — 받는 동안 그 단추만 바쁨, 마지막 쪽이면 단추가 사라지고 초점은 새로 붙은 첫 줄로', async () => {
const second = deferredReply()
const wrapper = await mountPage(pagedReply(() => second.promise))
const more = wrapper.get('button[data-release-notes-more]')
expect(more.text()).toBe('더 보기')
;(more.element as HTMLButtonElement).focus()
await more.trigger('click')
expect(more.attributes('aria-busy')).toBe('true')
expect(more.attributes('aria-disabled')).toBe('true')
expect(wrapper.findAll('a[data-release-note-row]')).toHaveLength(20)
second.resolve(ok(lastPage(MANY.slice(20))))
await flushPromises()
const rows = wrapper.findAll('a[data-release-note-row]')
expect(rows).toHaveLength(22)
expect(wrapper.find('[data-release-notes-more]').exists()).toBe(false)
expect(document.activeElement).toBe(rows[20]?.element)
expect(requests.map((request) => request.params)).toEqual([{ limit: 20 }, { cursor: 'c20', limit: 20 }])
})
it('더 받지 못하면 알림으로 알리고 받은 줄 · 단추 · 초점은 그대로', async () => {
const wrapper = await mountPage(pagedReply(() => fail(503, 'SYS_001')))
const more = wrapper.get('button[data-release-notes-more]')
;(more.element as HTMLButtonElement).focus()
await more.trigger('click')
await flushPromises()
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([ERROR_CODE_MESSAGES.SYS_001])
expect(wrapper.findAll('a[data-release-note-row]')).toHaveLength(20)
expect(more.attributes('aria-busy')).toBeUndefined()
expect(document.activeElement).toBe(more.element)
})
})
it('불러오는 중 — 머리 줄은 두고 줄 자리를 막대 넷으로, [더 보기]는 없다', async () => {
const reply = deferredReply()
const wrapper = await mountPage(() => reply.promise)
const box = wrapper.get('[data-release-notes-loading]')
expect(box.attributes('aria-busy')).toBe('true')
expect(box.attributes('aria-label')).toBe('업데이트 내역 불러오는 중')
expect(box.find('.release-note-list__head').exists()).toBe(true)
expect(box.findAll('.release-note-list__skeleton-row')).toHaveLength(4)
expect(wrapper.find('a[data-release-note-row]').exists()).toBe(false)
expect(wrapper.find('[data-release-notes-more]').exists()).toBe(false)
reply.resolve(ok(lastPage(BOARD_NOTES)))
await flushPromises()
expect(wrapper.find('[data-release-notes-loading]').exists()).toBe(false)
expect(wrapper.findAll('a[data-release-note-row]')).toHaveLength(4)
})
it('받지 못함 — 표 자리에 사전 문구 · [다시 시도] · 문의 코드, 알림은 띄우지 않는다. [다시 시도]는 다시 받는다', async () => {
let attempt = 0
const wrapper = await mountPage(() => {
attempt += 1
return attempt === 1 ? fail(503, 'SYS_001') : ok(lastPage(BOARD_NOTES))
})
const error = wrapper.get('[data-release-notes-error]')
expect(error.attributes('role')).toBe('alert')
expect(error.text()).toContain(ERROR_CODE_MESSAGES.SYS_001)
expect(error.text()).toContain('문의 코드 3f1c0a2e')
expect(wrapper.find('.release-note-list').exists()).toBe(false)
expect(useToastStore().toasts).toEqual([])
await error.get('button').trigger('click')
await flushPromises()
expect(requestLines()).toEqual(['GET /release-notes', 'GET /release-notes'])
expect(wrapper.findAll('a[data-release-note-row]')).toHaveLength(4)
})
it('목록이 404 여도 빠져나갈 곳이 없어 [다시 시도]다', async () => {
const wrapper = await mountPage(() => fail(404, 'RES_001'))
const error = wrapper.get('[data-release-notes-error]')
expect(error.text()).toContain(ERROR_CODE_MESSAGES.RES_001)
expect(error.get('button').text()).toBe('다시 시도')
await error.get('button').trigger('click')
await flushPromises()
expect(requestLines()).toEqual(['GET /release-notes', 'GET /release-notes'])
})
it('글이 없을 때 — 표와 [더 보기] 없이 한 줄만', async () => {
const wrapper = await mountPage(() => ok(lastPage([])))
const empty = wrapper.get('[data-release-notes-empty]')
expect(empty.text()).toBe('아직 알려 드릴 업데이트가 없습니다.')
expect(empty.attributes('role')).toBeUndefined()
expect(wrapper.find('.release-note-list').exists()).toBe(false)
expect(wrapper.find('[data-release-notes-more]').exists()).toBe(false)
})
// 2026-11-10 실사용 시험 — 좁은 폭에서 글에서 돌아오면 앞 화면의 스크롤 자리를 이어받아 목록의 맨 아래 근처가 보였다
it('받은 뒤 목록의 맨 위가 보이게 맞춘다 — 위에 메뉴가 길면 목록 칸의 위 끝을 화면 위 끝에, [더 보기] 뒤에는 맞추지 않는다', async () => {
// 휴대폰 360×800 — 문서를 1007 내린 채, 문서 맨 위에서 목록 칸은 906 · 제목 아래 끝은 931 (창 높이 768 을 넘는다)
document.documentElement.scrollTop = 1007
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) {
if (this.matches('main')) return { top: 906 - 1007, bottom: 0 } as DOMRect
if (this.matches('h1')) return { top: 0, bottom: 931 - 1007 } as DOMRect
return { top: 0, bottom: 0 } as DOMRect
})
const second = deferredReply()
const wrapper = await mountPage((request) =>
request.params.cursor === 'c20'
? second.promise
: ok({ items: MANY.slice(0, 20), pageInfo: { hasNext: true, nextCursor: 'c20', total: 22 } }),
)
expect(scrollTo.mock.calls).toEqual([[{ top: 906, behavior: 'instant' }]])
await wrapper.get('button[data-release-notes-more]').trigger('click')
second.resolve(ok(lastPage(MANY.slice(20))))
await flushPromises()
expect(wrapper.findAll('a[data-release-note-row]')).toHaveLength(22)
expect(scrollTo).toHaveBeenCalledTimes(1)
})
it('문서가 맨 위에 있으면(처음 연 화면) 문서는 그대로 두고, 따로 스크롤하는 칸(넓은 화면)은 [다시 시도]로 받은 뒤에도 맨 위로', async () => {
let attempt = 0
const wrapper = await mountPage(() => {
attempt += 1
return attempt === 1 ? fail(503, 'SYS_001') : ok(lastPage(BOARD_NOTES))
})
const main = wrapper.get('main').element as HTMLElement
main.scrollTop = 300
await wrapper.get('[data-release-notes-error] button').trigger('click')
await flushPromises()
expect(wrapper.findAll('a[data-release-note-row]')).toHaveLength(4)
expect(main.scrollTop).toBe(0)
expect(scrollTo).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,133 @@
<script setup lang="ts">
// 설정 › 정보 › 업데이트 내역 — 목록 (디자인 원본 ReleaseNotes A1 · A3 · README "업데이트 내역") — /settings/info/release-notes · 탭 제목 "업데이트 내역"
// 머리 "업데이트 내역" + 설명 한 줄 → 목록 칸(ReleaseNoteList — 최근 글부터, 줄을 누르면 그 글) → 더 있으면 [더 보기](44)
// - 들어올 때마다 새로 받는다(release-notes.store). 받는 동안은 머리 줄 + 막대 넷. 받은 뒤 목록의 맨 위가 보이게 스크롤을 맞춘다 —
// 글에서 돌아왔을 때 앞 화면의 스크롤 자리를 이어받지 않게(revealSettingsPageTop — 넓은 화면은 글 칸, 좁은 화면은 문서). [더 보기] 뒤에는 맞추지 않는다
// - 받지 못함: 칸 자리에 사전 문구 · [다시 시도] · 문의 코드(MailLoadError — 알림 없음). 목록은 빠져나갈 곳이 없어 없는 주소(404)여도 [다시 시도]
// - 글이 없음: "아직 알려 드릴 업데이트가 없습니다." 한 줄(표 · [더 보기] 없이)
// - [더 보기]: 20개씩 이어 받는다 — 받는 동안 그 단추만 바쁨(aria-busy · aria-disabled), 받지 못하면(응답의 모양이 틀려도) 알리고 받은 줄은 그대로.
// 마지막 쪽을 받아 단추가 사라지며 초점을 잃었으면 새로 붙은 첫 줄로 옮긴다
import { nextTick, ref } from 'vue'
import StateMessage from '@/components/feedback/StateMessage.vue'
import MailLoadError from '@/components/mail/MailLoadError.vue'
import ReleaseNoteList from '@/components/settings/ReleaseNoteList.vue'
import SettingsPageFrame from '@/components/settings/SettingsPageFrame.vue'
import { revealSettingsPageTop } from '@/composables/useSettingsPageScroll'
import { RELEASE_NOTES_TEXT } from '@/shared/constants/release-notes'
import { useReleaseNotesStore } from '@/stores/release-notes.store'
const TITLE_ID = 'release-notes-page-title'
const releaseNotesStore = useReleaseNotesStore()
const frameRef = ref<InstanceType<typeof SettingsPageFrame> | null>(null)
const listRef = ref<InstanceType<typeof ReleaseNoteList> | null>(null)
/** 누른 단추가 사라져 초점을 잃었는지 */
function focusLost(): boolean {
const active = document.activeElement
return active === null || active === document.body || !active.isConnected
}
/** 첫 쪽부터 받고, 그린 뒤 목록의 맨 위(제목)가 보이게 맞춘다 — 화면을 떠났으면 그대로 */
async function reload() {
await releaseNotesStore.loadList()
await nextTick()
const main: unknown = frameRef.value?.$el
if (!(main instanceof HTMLElement)) return
revealSettingsPageTop(main, main.querySelector<HTMLElement>('h1') ?? main)
}
async function loadMore() {
const firstAdded = await releaseNotesStore.loadMore()
if (firstAdded === null) return
await nextTick()
if (focusLost()) listRef.value?.focusRow(firstAdded)
}
// 처음 그리기 전에 — 지난번에 받아 둔 목록을 잠깐 그렸다 바꾸지 않게
void reload()
</script>
<template>
<SettingsPageFrame
ref="frameRef"
:title-id="TITLE_ID"
:title="RELEASE_NOTES_TEXT.title"
:description="RELEASE_NOTES_TEXT.description"
>
<MailLoadError
v-if="releaseNotesStore.listStatus === 'error' && releaseNotesStore.listError"
:error="releaseNotesStore.listError"
:leave-label="RELEASE_NOTES_TEXT.retry"
data-release-notes-error
@retry="reload"
@leave="reload"
/>
<StateMessage
v-else-if="releaseNotesStore.listStatus === 'ready' && releaseNotesStore.notes.length === 0"
icon="file-text"
:title="RELEASE_NOTES_TEXT.empty"
data-release-notes-empty
/>
<div
v-else
class="release-notes"
>
<ReleaseNoteList
ref="listRef"
:notes="releaseNotesStore.notes"
:labelledby="TITLE_ID"
:loading="releaseNotesStore.listStatus !== 'ready'"
/>
<div
v-if="releaseNotesStore.hasMore"
class="release-notes__more"
>
<button
type="button"
class="release-notes__more-button"
:aria-disabled="releaseNotesStore.loadingMore ? 'true' : undefined"
:aria-busy="releaseNotesStore.loadingMore ? 'true' : undefined"
data-release-notes-more
@click="loadMore"
>
{{ RELEASE_NOTES_TEXT.more }}
</button>
</div>
</div>
</SettingsPageFrame>
</template>
<style scoped>
.release-notes {
display: flex;
flex-direction: column;
gap: var(--space-16);
}
.release-notes__more {
display: flex;
}
/* [더 보기] 44 — 흰 바탕 · 테두리 (로그인 기록의 [더 보기]와 같다) */
.release-notes__more-button {
min-height: var(--size-touch);
padding: 0 var(--space-16);
border: var(--border-width) solid var(--color-border-strong);
border-radius: var(--radius-md);
background: var(--color-surface);
color: var(--color-text-strong);
font-weight: 600;
}
.release-notes__more-button:hover:not([aria-disabled='true']) {
background: var(--color-surface-muted);
}
.release-notes__more-button[aria-disabled='true'] {
opacity: 0.6;
cursor: progress;
}
</style>
@@ -6,12 +6,14 @@ import { createMemoryHistory, createRouter } from 'vue-router'
import { routes } from '@/router/routes'
import { useAuthStore } from '@/stores/auth.store'
import { flushPromises } from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
import SettingsLayout from './SettingsLayout.vue'
// 설정 메뉴 — 계정 › 비밀번호 · 2단계 인증 · 앱 비밀번호 · 메일 프로그램 연결 · 휴대폰 앱 · 로그인 기록 / 메일 › 서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 · 차단한 주소 · 대용량 첨부 · 알림
// / 정보 › 업데이트 내역
// (디자인 README "4단계 › 설정 메뉴" · "5단계" · "6단계" · "9단계 › 설정 · 보내기 · 읽기 · 차단한 주소" · "11단계 › 설정 › 메일 › 대용량 첨부" · "12단계 — 계정 보안" ·
// "13단계" · "휴대폰 앱 받기")
// "13단계" · "휴대폰 앱 받기" · "업데이트 내역")
describe('SettingsLayout', () => {
let pinia: Pinia
@@ -36,13 +38,17 @@ describe('SettingsLayout', () => {
return { wrapper, router }
}
it('묶음 둘 — 계정(비밀번호 · 2단계 인증 · 앱 비밀번호 · 메일 프로그램 연결 · 휴대폰 앱 · 로그인 기록) · 메일(서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 · 차단한 주소 · 대용량 첨부 · 알림), 묶음 이름이 목록의 이름이다', async () => {
it('묶음 셋 — 계정(비밀번호 · 2단계 인증 · 앱 비밀번호 · 메일 프로그램 연결 · 휴대폰 앱 · 로그인 기록) · 메일(서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 · 차단한 주소 · 대용량 첨부 · 알림) · 정보(업데이트 내역), 묶음 이름이 목록의 이름이다', async () => {
const { wrapper } = await mountAt('/settings/account/password')
const menu = wrapper.get('nav[aria-label="설정 메뉴"]')
expect(menu.findAll('.settings-menu__group').map((group) => group.text())).toEqual(['계정', '메일'])
expect(menu.findAll('.settings-menu__group').map((group) => group.text())).toEqual(['계정', '메일', '정보'])
const lists = menu.findAll('ul')
expect(lists.map((list) => list.attributes('aria-labelledby'))).toEqual(['settings-menu-account', 'settings-menu-mail'])
expect(lists.map((list) => list.attributes('aria-labelledby'))).toEqual([
'settings-menu-account',
'settings-menu-mail',
'settings-menu-info',
])
expect(lists.map((list) => list.findAll('a').map((link) => [link.text(), link.attributes('href')]))).toEqual([
[
['비밀번호', '/settings/account/password'],
@@ -61,9 +67,69 @@ describe('SettingsLayout', () => {
['대용량 첨부', '/settings/mail/large-files'],
['알림', '/settings/mail/notify'],
],
[['업데이트 내역', '/settings/info/release-notes']],
])
})
it('설정 › 정보 › 업데이트 내역 — 목록 /settings/info/release-notes · 글 /settings/info/release-notes/<글 id>, 로그인이 필요하고 탭 제목은 둘 다 "업데이트 내역" (앱의 라우트 표 그대로)', async () => {
const router = createRouter({ history: createMemoryHistory(), routes })
await router.push('/settings/info/release-notes')
expect(router.currentRoute.value.name).toBe('settings-info-release-notes')
expect(router.currentRoute.value.meta).toMatchObject({ title: '업데이트 내역', requiresAuth: true })
await router.push('/settings/info/release-notes/2026-11-18-2')
expect(router.currentRoute.value.name).toBe('settings-info-release-note')
expect(router.currentRoute.value.params.id).toBe('2026-11-18-2')
expect(router.currentRoute.value.meta).toMatchObject({ title: '업데이트 내역', requiresAuth: true })
expect(router.resolve({ name: 'settings-info-release-notes' }).fullPath).toBe('/settings/info/release-notes')
expect(router.resolve({ name: 'settings-info-release-note', params: { id: '2026-11-18' } }).fullPath).toBe(
'/settings/info/release-notes/2026-11-18',
)
})
it('/settings/info 는 업데이트 내역 목록으로 바꿔 끼운다 — 글 주소의 ".." 를 브라우저가 다듬은 주소, 다른 묶음 주소는 그대로', async () => {
const router = createRouter({ history: createMemoryHistory(), routes })
for (const path of ['/settings/info', '/settings/info/']) {
await router.push(path)
expect(router.currentRoute.value.name, path).toBe('settings-info-release-notes')
expect(router.currentRoute.value.fullPath, path).toBe('/settings/info/release-notes')
}
expect(router.resolve('/settings/account').name).toBe('not-found')
expect(router.resolve('/settings/mail').name).toBe('not-found')
})
it('업데이트 내역 메뉴는 문서 아이콘(file-text) — 목록이면 aria-current="page", 글을 보는 동안에는 고른 모양 그대로 aria-current="true"', async () => {
for (const [path, current] of [
['/settings/info/release-notes', 'page'],
['/settings/info/release-notes/2026-11-18', 'true'],
] as const) {
const { wrapper } = await mountAt(path)
const marked = wrapper.get('nav[aria-label="설정 메뉴"]').findAll('a[aria-current]')
expect(marked.map((link) => [link.text(), link.attributes('aria-current')]), path).toEqual([['업데이트 내역', current]])
expect(marked[0]?.classes()).toContain('is-active')
expect(marked[0]?.find('path').attributes('d')).toBe('M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z')
expect(marked[0]?.find('polyline').attributes('points')).toBe('14 2 14 8 20 8')
wrapper.unmount()
}
})
it('메뉴 항목을 누르면 그 화면으로 간다 — 글을 보다가 업데이트 내역을 누르면 목록', async () => {
const { wrapper, router } = await mountAt('/settings/info/release-notes/2026-11-18')
const item = wrapper
.get('nav[aria-label="설정 메뉴"]')
.findAll('a')
.find((link) => link.text() === '업데이트 내역')
await item?.trigger('click', { button: 0 })
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/settings/info/release-notes')
})
it('지금 화면의 항목만 현재로 표시한다', async () => {
const { wrapper } = await mountAt('/settings/mail/vacation')
const current = wrapper.get('nav[aria-label="설정 메뉴"]').findAll('a[aria-current="page"]')
+19 -9
View File
@@ -1,7 +1,10 @@
<script setup lang="ts">
// 설정 모듈 레이아웃 (디자인 원본 Settings · SettingsAccount · SettingsVacation) — 전역 셸 + 왼쪽 설정 메뉴(208 · #F8F9FB) + 하위 화면
// 메뉴는 묶음 둘: 계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 · 필터 · 알림 (디자인 README "4단계 › 설정 메뉴" · "5단계" · "6단계", 목록은 settings-menu.ts).
// 묶음 이름은 12px #667085 굵게, 항목은 40 · 아이콘 16. 지금 화면의 항목은 aria-current="page" (RouterLink).
// 메뉴는 묶음 셋: 계정 › 비밀번호 … / 메일 › 서명 · 부재중 자동 응답 · 필터 · 알림 … / 정보 › 업데이트 내역
// (디자인 README "4단계 › 설정 메뉴" · "5단계" · "6단계" · "업데이트 내역", 목록은 settings-menu.ts).
// 묶음 이름은 12px #667085 굵게, 항목은 40 · 아이콘 16. 지금 화면의 항목은 청록 바탕 + aria-current="page" 다 —
// 그 항목 아래에 묶은 화면(업데이트 내역의 글)을 보는 동안에도 고른 상태(청록 바탕)로 두되, 링크가 가리키는 곳(목록)과 지금 쪽(글)이 달라
// aria-current 는 "true" 다(RouterLink 의 isExactActive · isActive — 메일함 패널의 지금 메일함과 같은 결)
import AppIcon from '@/components/AppIcon.vue'
import AppShell from '@/components/shell/AppShell.vue'
import { SETTINGS_MENU } from '@/shared/constants/settings-menu'
@@ -36,15 +39,22 @@ function groupLabelId(groupId: string): string {
:key="item.routeName"
>
<RouterLink
v-slot="{ href, navigate, isActive, isExactActive }"
:to="{ name: item.routeName }"
class="settings-menu__link"
active-class="is-active"
custom
>
<AppIcon
:name="item.icon"
:size="16"
/>
<span>{{ item.label }}</span>
<a
:href="href"
:class="['settings-menu__link', { 'is-active': isActive }]"
:aria-current="isExactActive ? 'page' : isActive ? 'true' : undefined"
@click="navigate"
>
<AppIcon
:name="item.icon"
:size="16"
/>
<span>{{ item.label }}</span>
</a>
</RouterLink>
</li>
</ul>
+14
View File
@@ -89,4 +89,18 @@ describe('router 가드', () => {
await router.push('/login?redirect=/settings/account/two-factor')
expect(router.currentRoute.value.fullPath).toBe('/settings/account/two-factor')
})
// 업데이트 내역 (2026-11-10 — 디자인 README "업데이트 내역") — 목록 · 글 모두 탭 제목 "업데이트 내역"
it('업데이트 내역의 목록 · 글은 로그인해야 열리고, 탭 제목은 둘 다 "업데이트 내역 · ARA SYSTEM"', async () => {
installMockApi(() => ok(USER))
useAuthStore().markAuthenticated(USER)
await router.push('/settings/info/release-notes')
expect(router.currentRoute.value.name).toBe('settings-info-release-notes')
expect(document.title).toBe('업데이트 내역 · ARA SYSTEM')
await router.push('/settings/info/release-notes/2026-11-18')
expect(router.currentRoute.value.name).toBe('settings-info-release-note')
expect(document.title).toBe('업데이트 내역 · ARA SYSTEM')
})
})
+25 -1
View File
@@ -9,6 +9,7 @@ import {
MOBILE_APP_ROUTE,
MOBILE_APP_SETTINGS_TEXT,
} from '@/shared/constants/mobile-app'
import { RELEASE_NOTES_TEXT } from '@/shared/constants/release-notes'
import { SETTINGS_ROUTES } from '@/shared/constants/settings-menu'
import {
MAIL_SCHEDULED_MESSAGE_ROUTE,
@@ -112,7 +113,7 @@ export const routes: RouteRecordRaw[] = [
},
{
// 설정 — 왼쪽 메뉴(계정 › 비밀번호 · 2단계 인증 · 앱 비밀번호 · 메일 프로그램 연결 · 휴대폰 앱 · 로그인 기록 / 메일 › 서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 ·
// 차단한 주소 · 대용량 첨부 · 알림) + 하위 화면. /settings 는 첫 메뉴(계정 › 비밀번호)로 바꿔 끼운다 (앱 레일의 "설정")
// 차단한 주소 · 대용량 첨부 · 알림 / 정보 › 업데이트 내역) + 하위 화면. /settings 는 첫 메뉴(계정 › 비밀번호)로 바꿔 끼운다 (앱 레일의 "설정")
path: '/settings',
component: () => import('@/pages/settings/SettingsLayout.vue'),
meta: { requiresAuth: true, title: '설정' },
@@ -201,6 +202,29 @@ export const routes: RouteRecordRaw[] = [
component: () => import('@/pages/settings/MailNotifyPage.vue'),
meta: { title: '메일 알림' },
},
{
// 업데이트 내역 (2026-11-10 — 디자인 README "업데이트 내역", 계약 7절) — 목록 /settings/info/release-notes · 글 /settings/info/release-notes/<글 id>.
// 둘을 화면 없는 상위 라우트로 묶었다 — 글을 보는 동안에도 메뉴의 업데이트 내역이 고른 상태다(메뉴 링크가 목록 라우트 = 빈 하위 경로라
// RouterLink 가 같은 상위의 다른 하위(글)도 고른 것으로 본다). 탭 제목은 둘 다 "업데이트 내역"
path: 'info/release-notes',
meta: { title: RELEASE_NOTES_TEXT.title },
// 주소창에 …/release-notes/.. (%2E%2E 도)를 치면 브라우저가 /settings/info/ 로 다듬는다 — 아래 'info' 가 목록으로 바꿔 끼운다
children: [
{
path: '',
name: SETTINGS_ROUTES.releaseNotes,
component: () => import('@/pages/settings/InfoReleaseNotesPage.vue'),
},
{
path: ':id',
name: SETTINGS_ROUTES.releaseNote,
component: () => import('@/pages/settings/InfoReleaseNotePage.vue'),
},
],
},
// /settings/info — 정보 묶음의 화면은 업데이트 내역 하나라 목록으로 바꿔 끼운다(글 주소의 ".." 를 브라우저가 다듬은 주소이기도 하다).
// 다른 묶음의 주소(/settings/account · /settings/mail)는 그대로다
{ path: 'info', redirect: { name: SETTINGS_ROUTES.releaseNotes } },
],
},
{
@@ -0,0 +1,33 @@
// 설정 › 정보 › 업데이트 내역 — 화면 글의 원본 (디자인 원본 ReleaseNotes A · README "업데이트 내역")
// 글은 보드 그대로다. 글을 고치면 디자인 README 의 그 절도 함께 고친다. 글 내용(제목 · 묶음 · 줄)은 서버가 주는 글 그대로 그린다 — 여기 두지 않는다.
export const RELEASE_NOTES_TEXT = {
/** 설정 메뉴의 셋째 묶음 이름 */
menuGroup: '정보',
/** 메뉴 항목 · 목록 화면 제목 · 탭 제목(목록 · 글 둘 다) */
title: '업데이트 내역',
/** 목록 화면 제목 아래 한 줄 — 글 화면에는 두지 않는다 */
description: '웹메일과 휴대폰 앱에 새로 생기거나 바뀐 것을 알려 드립니다.',
/** 목록 머리 줄 (보이는 꼴만 — 화면 낭독은 줄 링크가 제목과 날짜를 함께 읽는다) */
columnTitle: '제목',
columnDate: '날짜',
/** 목록을 불러오는 동안 막대 자리의 이름 */
listLoading: '업데이트 내역 불러오는 중',
/** 글이 하나도 없을 때 */
empty: '아직 알려 드릴 업데이트가 없습니다.',
more: '더 보기',
/** 목록을 받지 못했을 때의 단추 — 목록은 빠져나갈 곳이 없어 없는 주소(404)여도 다시 받는다 */
retry: '다시 시도',
/** 글 화면 위 줄의 [← 목록으로] · 없는 글의 단추 */
backToList: '목록으로',
/** 글 끝의 [목록] */
list: '목록',
/** 글을 불러오는 동안 막대 자리의 이름 */
noteLoading: '글 불러오는 중',
/** 글 끝의 다른 글 줄 묶음 */
neighbors: '다른 글',
/** 바로 더 새 글 (서버의 next) — 위 줄 */
newer: '다음 글',
/** 바로 더 옛 글 (서버의 previous) — 아래 줄 */
older: '이전 글',
} as const
+11 -2
View File
@@ -1,12 +1,13 @@
// 설정 모듈의 왼쪽 메뉴 — 묶음(계정 · 메일) 아래 항목 (디자인 README "4단계 › 설정 메뉴" · "5단계 › 설정 › 메일 › 알림" · "6단계 › 설정 › 메일 › 필터" ·
// 설정 모듈의 왼쪽 메뉴 — 묶음(계정 · 메일 · 정보) 아래 항목 (디자인 README "4단계 › 설정 메뉴" · "5단계 › 설정 › 메일 › 알림" · "6단계 › 설정 › 메일 › 필터" ·
// "9단계 › 설정 · 보내기 · 읽기 · 차단한 주소" · "11단계 › 설정 › 메일 › 대용량 첨부" — 차단한 주소 아래 ·
// "12단계 — 계정 보안" — 계정 › 비밀번호 · 2단계 인증 · 앱 비밀번호 · 로그인 기록 · "13단계" — 앱 비밀번호 아래 메일 프로그램 연결 ·
// "휴대폰 앱 받기"(2026-11-08) — 메일 프로그램 연결 아래 휴대폰 앱)
// "휴대폰 앱 받기"(2026-11-08) — 메일 프로그램 연결 아래 휴대폰 앱 · "업데이트 내역"(2026-11-10) — 셋째 묶음 정보 › 업데이트 내역)
// 라우트(router/routes.ts) · 메뉴(SettingsLayout.vue) · 다른 화면의 링크(메일함 패널의 부재중 안내 · 알림을 막아 둔 오류 알림의 [설정 열기] ·
// 읽기 화면 ⋯ 「이런 메일로 필터 만들기」 · 계정 보안 화면끼리의 링크 · 휴대폰 앱의 아이폰 안내)가 이 이름을 함께 쓴다.
import type { IconName } from '@/shared/constants/icons'
import { MOBILE_APP_SETTINGS_TEXT } from '@/shared/constants/mobile-app'
import { RELEASE_NOTES_TEXT } from '@/shared/constants/release-notes'
export const SETTINGS_ROUTES = {
/** 설정의 첫 화면 — /settings 는 이 화면으로 바꿔 끼운다 (앱 레일의 "설정") */
@@ -29,6 +30,9 @@ export const SETTINGS_ROUTES = {
/** 대용량 첨부 — 보관 사용량 · 보내지 않은 파일 · 보낸 링크 (11단계) */
largeFiles: 'settings-mail-large-files',
notify: 'settings-mail-notify',
/** 업데이트 내역 — 목록 · 글 (2026-11-10). 글을 보는 동안에도 메뉴의 업데이트 내역이 고른 상태다(라우트를 목록 아래에 묶었다) */
releaseNotes: 'settings-info-release-notes',
releaseNote: 'settings-info-release-note',
} as const
export interface SettingsMenuItem {
@@ -70,4 +74,9 @@ export const SETTINGS_MENU: readonly SettingsMenuGroup[] = [
{ routeName: SETTINGS_ROUTES.notify, label: '알림', icon: 'bell' },
],
},
{
id: 'info',
label: RELEASE_NOTES_TEXT.menuGroup,
items: [{ routeName: SETTINGS_ROUTES.releaseNotes, label: RELEASE_NOTES_TEXT.title, icon: 'file-text' }],
},
]
@@ -0,0 +1,27 @@
// 업데이트 내역 API 공유 타입 — 서버와 맞춘 약속(2026-11-10 — 계약 7절 "업데이트 내역")과 같은 구조
// GET /api/release-notes?cursor=&limit= → { items, pageInfo } (최근 글부터) · GET /api/release-notes/{id} (없는 글 · 모양이 틀린 id 는 404 RES_001)
// 글은 서버가 다듬은 글 그대로다 — 화면은 마크다운 해석 · v-html 없이 글로만 그린다
/** 목록의 한 줄 · 글 화면의 다른 글 줄 */
export interface ReleaseNoteSummary {
/** 글 파일 이름 — "2026-11-18" · 같은 날 둘째부터 "2026-11-18-2" */
id: string
/** 달력 날짜 "2026-11-18" — 시각이 아니다(화면은 시간대를 바꾸지 않고 그대로 쓴다 — formatCalendarDate) */
date: string
title: string
}
/** 글의 묶음 — 머리(없으면 null) + 점 목록 */
export interface ReleaseNoteSection {
heading: string | null
items: string[]
}
/** 글 하나 */
export interface ReleaseNote extends ReleaseNoteSummary {
sections: ReleaseNoteSection[]
/** 바로 더 옛 글 (목록 차례의 아래) — 없으면 null */
previous: ReleaseNoteSummary | null
/** 바로 더 새 글 (목록 차례의 위) — 없으면 null */
next: ReleaseNoteSummary | null
}
@@ -0,0 +1,58 @@
import { afterEach, describe, expect, it } from 'vitest'
import { eventPathHas } from './event-path'
// 사건이 지나간 길 — 앞선 처리기가 target 을 문서에서 떼어 내도 dispatch 때의 길로 가른다 (2026-11-10 — 목록 보기의 고르기 상자)
describe('eventPathHas', () => {
afterEach(() => {
document.body.innerHTML = ''
})
/** <a-wrap> 안의 줄(tr 대신 div) — 줄 > 칸 > 단추 > 그림 */
function build() {
document.body.innerHTML =
'<a id="outer"><div id="row"><div id="cell" class="cell"><button id="button"><svg><rect id="picture" /></svg></button></div><span id="text">글</span></div></a>'
const get = (id: string) => document.getElementById(id) as Element
return { outer: get('outer'), row: get('row'), cell: get('cell'), button: get('button'), picture: get('picture'), text: get('text') }
}
/** 줄에 단 처리기에서 본 값 */
function seenByRow(row: Element, target: Element, selector: string, before?: () => void): { path: boolean; closest: boolean } {
let seen = { path: false, closest: false }
const listener = (event: Event) => {
seen = {
path: eventPathHas(event, selector),
closest: event.target instanceof Element && event.target.closest(selector) !== null,
}
}
row.addEventListener('click', listener)
if (before) target.parentElement?.closest('.cell')?.addEventListener('click', before)
target.dispatchEvent(new MouseEvent('click', { bubbles: true }))
row.removeEventListener('click', listener)
return seen
}
it('그림을 누른 click — 단추의 처리기 뒤 그림이 문서에서 떨어져도 길에는 단추가 있다 (closest 는 비었다)', () => {
const { row, picture } = build()
const seen = seenByRow(row, picture, 'button', () => picture.remove())
expect(picture.isConnected).toBe(false)
expect(seen).toEqual({ path: true, closest: false })
})
it('칸의 빈자리 · 칸 밖은 그 선택자가 길에 없다', () => {
const { row, cell, text } = build()
expect(seenByRow(row, cell, 'button').path).toBe(false)
expect(seenByRow(row, cell, '.cell').path).toBe(true)
expect(seenByRow(row, text, 'button, .cell').path).toBe(false)
})
it('사건을 받은 요소(currentTarget)까지만 본다 — 줄을 감싼 바깥 링크는 세지 않는다', () => {
const { row, text } = build()
expect(seenByRow(row, text, 'a').path).toBe(false)
})
})
+14
View File
@@ -0,0 +1,14 @@
// 사건이 지나간 길 — dispatch 할 때 정해져, 앞선 처리기가 화면을 다시 그려 target 이 문서에서 떨어져도 그대로다.
// 줄 전체에 click 을 걸고 "단추 · 링크를 눌렀는가"를 가르는 곳(목록 보기의 줄 — MessageTableRow · ConversationTableRow)은
// target.closest 대신 이것으로 본다 (2026-11-10 사용자 보고 — 목록 보기에서 고르기 상자 가운데를 누르면 target 은 상자 그림(<rect>)인데,
// 단추의 처리기가 고르기를 바꾸자 처리기 사이(microtask)에 Vue 가 다시 그려 그 그림을 떼어 냈다. 줄의 처리기에서 closest('button') 이
// 비어 줄을 누른 것으로 보고 메일까지 열었다)
/** 사건이 지나간 길에서 그 사건을 받은 요소(currentTarget)까지 — 선택자에 맞는 요소가 있었는가 */
export function eventPathHas(event: Event, selector: string): boolean {
for (const node of event.composedPath()) {
if (node instanceof Element && node.matches(selector)) return true
if (node === event.currentTarget) return false
}
return false
}
+42
View File
@@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest'
import {
formatBlockedDay,
formatCalendarDate,
isCalendarDate,
formatConversationTime,
formatDraftSavedTime,
formatPrintDateTime,
@@ -237,3 +239,43 @@ describe('formatConversationTime (14단계 — 같은 대화 칸의 시각, 디
expect(formatConversationTime('나중에', now, SEOUL)).toBe('')
})
})
describe('formatCalendarDate (업데이트 내역 — 달력 날짜 글, 디자인 원본 ReleaseNotes)', () => {
it('"2026-11-18" → "2026년 11월 18일" — 달 · 날의 앞 0 은 쓰지 않는다', () => {
expect(formatCalendarDate('2026-11-18')).toBe('2026년 11월 18일')
expect(formatCalendarDate('2026-11-05')).toBe('2026년 11월 5일')
expect(formatCalendarDate('2027-01-01')).toBe('2027년 1월 1일')
})
it('시간대를 거치지 않는다 — 해가 바뀌는 날도 쓴 날 그대로 (Date 로 읽으면 UTC 자정이라 서쪽 시간대에서 하루 앞 · 지난해가 된다)', () => {
// 이 시험이 도는 시간대가 어디든(개발 PC 는 KST · CI 는 UTC — TZ=UTC · America/Los_Angeles 로도 돌려 보았다) 글이 같다
expect(formatCalendarDate('2026-01-01')).toBe('2026년 1월 1일')
expect(formatCalendarDate('2026-12-31')).toBe('2026년 12월 31일')
})
it('윤년의 2월 29일은 받고, 실제 없는 날 · 꼴이 틀린 글은 빈 글', () => {
expect(formatCalendarDate('2028-02-29')).toBe('2028년 2월 29일')
expect(formatCalendarDate('2000-02-29')).toBe('2000년 2월 29일')
expect(formatCalendarDate('2026-02-29')).toBe('')
expect(formatCalendarDate('2100-02-29')).toBe('')
expect(formatCalendarDate('2026-02-30')).toBe('')
expect(formatCalendarDate('2026-04-31')).toBe('')
expect(formatCalendarDate('2026-13-01')).toBe('')
expect(formatCalendarDate('2026-00-10')).toBe('')
expect(formatCalendarDate('2026-11-00')).toBe('')
expect(formatCalendarDate('2026-11-18T00:00:00Z')).toBe('')
expect(formatCalendarDate('2026-11-18-2')).toBe('')
expect(formatCalendarDate('2026-1-8')).toBe('')
expect(formatCalendarDate('')).toBe('')
})
})
describe('isCalendarDate (업데이트 내역 응답 읽기 — 실제 있는 달력 날짜)', () => {
it('"YYYY-MM-DD" 이고 실제 있는 날만 참', () => {
expect(isCalendarDate('2026-11-18')).toBe(true)
expect(isCalendarDate('2028-02-29')).toBe(true)
expect(isCalendarDate('2026-02-29')).toBe(false)
expect(isCalendarDate('2026-11-18T00:00:00Z')).toBe(false)
expect(isCalendarDate('')).toBe(false)
})
})
+39
View File
@@ -337,6 +337,45 @@ export function formatLongDate(value: string, timeZone?: string): string {
return new Intl.DateTimeFormat('ko-KR', { timeZone, year: 'numeric', month: 'long', day: 'numeric' }).format(date)
}
/** 달력 날짜 글 "YYYY-MM-DD" — 시각이 아니다 (업데이트 내역의 글 날짜 — 계약 7절) */
const CALENDAR_DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/
const THIRTY_DAY_MONTHS: ReadonlySet<number> = new Set([4, 6, 9, 11])
function isLeapYear(year: number): boolean {
return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0
}
function daysInMonth(year: number, month: number): number {
if (month === 2) return isLeapYear(year) ? 29 : 28
return THIRTY_DAY_MONTHS.has(month) ? 30 : 31
}
/** 달력 날짜 글을 연 · 월 · 일로 — 꼴이 틀렸거나 실제 없는 날(2026-02-30)이면 null */
function calendarDateParts(value: string): CalendarDay | null {
const match = CALENDAR_DATE_PATTERN.exec(value)
if (!match) return null
const year = Number(match[1])
const month = Number(match[2])
const day = Number(match[3])
if (month < 1 || month > 12 || day < 1 || day > daysInMonth(year, month)) return null
return { year, month, day }
}
/** 실제 있는 날의 달력 날짜 글 "YYYY-MM-DD" 인지 (업데이트 내역 응답 읽기 — 앱의 CalendarDate.tryParse 와 같다) */
export function isCalendarDate(value: string): boolean {
return calendarDateParts(value) !== null
}
/**
* 달력 날짜 글을 연도까지 쓴 날로 — "2026-11-18" → "2026년 11월 18일" (디자인 원본 ReleaseNotes).
* 시간대를 거치지 않는다 — new Date('2026-11-18') 은 UTC 자정이라 서쪽 시간대(UTC−)에서는 하루 앞 날짜가 된다.
* 꼴이 틀렸거나 실제 없는 날(2026-02-30)이면 ''
*/
export function formatCalendarDate(value: string): string {
const parts = calendarDateParts(value)
return parts ? `${parts.year}년 ${parts.month}월 ${parts.day}일` : ''
}
/** 문의 코드로 보여 줄 traceId 앞자리 수 (디자인 원본 States: "문의 코드 3f1c0a2e") */
export const TRACE_CODE_LENGTH = 8
@@ -0,0 +1,149 @@
import { describe, expect, it } from 'vitest'
import {
RELEASE_NOTE_ID_MAX_LENGTH,
isReleaseNoteId,
releaseNoteNeighbors,
releaseNoteOf,
releaseNotePageOf,
releaseNoteSummaryOf,
} from './release-notes'
// 업데이트 내역 — 글 id 꼴 · 응답 읽기 · 다른 글 줄 (계약 7절 "업데이트 내역", 디자인 원본 ReleaseNotes)
const TOP = { id: '2026-11-18', date: '2026-11-18', title: '메일 검색이 빨라지고 휴대폰 앱이 나왔습니다' }
const MID = { id: '2026-11-05', date: '2026-11-05', title: '화면의 안내 글을 다듬었습니다' }
const MENU = { id: '2026-11-04', date: '2026-11-04', title: '왼쪽 메뉴를 정리했습니다' }
describe('isReleaseNoteId (주소의 글 id 꼴)', () => {
it('파일 이름 꼴만 받는다 — "2026-11-18" · 같은 날 둘째부터 "2026-11-18-2"', () => {
expect(isReleaseNoteId('2026-11-18')).toBe(true)
expect(isReleaseNoteId('2026-11-18-2')).toBe(true)
expect(isReleaseNoteId('2026-11-18-12')).toBe(true)
})
it('경로 조각 "." · ".." · 다른 글자 · 빈 글 · 32자 넘음은 받지 않는다 — 묻지 않고 없는 글', () => {
for (const value of ['.', '..', '', '2026-11-18.md', '2026-11-18-', '2026-11-18-x', 'latest', '2026/11/18', ' 2026-11-18', '2026-11-18 ']) {
expect(isReleaseNoteId(value), value).toBe(false)
}
const long = `2026-11-18-${'1'.repeat(RELEASE_NOTE_ID_MAX_LENGTH - 10)}`
expect(long.length).toBe(RELEASE_NOTE_ID_MAX_LENGTH + 1)
expect(isReleaseNoteId(long)).toBe(false)
expect(isReleaseNoteId(long.slice(0, -1))).toBe(true)
})
})
describe('releaseNotePageOf (목록 응답)', () => {
it('{ items, pageInfo } 를 그대로 읽는다', () => {
const page = releaseNotePageOf({ items: [TOP, MID], pageInfo: { hasNext: true, nextCursor: 'c2', total: 24 } })
expect(page).toEqual({ items: [TOP, MID], pageInfo: { hasNext: true, nextCursor: 'c2', total: 24 } })
})
it('모르는 칸은 버린다 · 커서가 없거나 빈 글이면 다음 쪽이 없는 것으로 · 수가 없으면 모름(null)', () => {
expect(releaseNotePageOf({ items: [{ ...TOP, extra: 1 }], pageInfo: { hasNext: false, nextCursor: null, total: 1 } })).toEqual({
items: [TOP],
pageInfo: { hasNext: false, nextCursor: null, total: 1 },
})
expect(releaseNotePageOf({ items: [], pageInfo: { hasNext: true, nextCursor: null, total: null } })?.pageInfo).toEqual({
hasNext: false,
nextCursor: null,
total: null,
})
expect(releaseNotePageOf({ items: [], pageInfo: { hasNext: true, nextCursor: '' } })?.pageInfo).toEqual({
hasNext: false,
nextCursor: null,
total: null,
})
})
it('모양이 틀리면(줄 하나라도) null — 받지 못한 것으로 본다(앱과 같다)', () => {
const pageInfo = { hasNext: false, nextCursor: null, total: 2 }
for (const reply of [
null,
'x',
{ items: [TOP] },
{ items: 'x', pageInfo },
{ items: [TOP], pageInfo: null },
{ items: [TOP], pageInfo: { hasNext: 'yes', nextCursor: null, total: 1 } },
{ items: [TOP], pageInfo: { hasNext: true, nextCursor: 3, total: 1 } },
{ items: [TOP], pageInfo: { hasNext: false, nextCursor: null, total: '1' } },
{ items: [TOP, null], pageInfo },
{ items: [TOP, { id: 3, date: '2026-11-05', title: '숫자 id' }], pageInfo },
{ items: [TOP, { id: '', date: '2026-11-05', title: '빈 id' }], pageInfo },
{ items: [TOP, { id: '2026-02-30', date: '2026-02-30', title: '없는 날' }], pageInfo },
{ items: [TOP, { id: 'a', date: 'b' }], pageInfo },
]) {
expect(releaseNotePageOf(reply), JSON.stringify(reply)).toBeNull()
}
})
})
describe('releaseNoteOf (글 응답)', () => {
const REPLY = {
...MID,
sections: [
{ heading: null, items: ['단추와 안내 글을 더 알기 쉬운 말로 고쳤습니다.'] },
{ heading: '주소록 · 설정', items: ['내 주소록을 파일로 내보낼 수 있습니다. [주소록] → [내보내기]'] },
],
previous: MENU,
next: TOP,
}
it('글 · 묶음 · 이전 글 · 다음 글을 그대로 읽는다', () => {
expect(releaseNoteOf(REPLY)).toEqual(REPLY)
})
it('빈 머리는 머리 없음 · previous · next 칸이 없으면 없는 것(null)으로 본다', () => {
expect(
releaseNoteOf({ ...TOP, sections: [{ heading: '', items: ['머리 없는 줄'] }, { items: ['머리 칸이 없는 줄'] }] }),
).toEqual({
...TOP,
sections: [
{ heading: null, items: ['머리 없는 줄'] },
{ heading: null, items: ['머리 칸이 없는 줄'] },
],
previous: null,
next: null,
})
})
it('모양이 틀리면(묶음 · 줄 · 다른 글 하나라도) null — 받지 못한 것으로 본다(앱과 같다)', () => {
for (const reply of [
undefined,
{ ...REPLY, title: null },
{ ...REPLY, date: '2026-11-31' },
{ items: [], pageInfo: {} },
{ ...REPLY, sections: undefined },
{ ...REPLY, sections: [...REPLY.sections, 'x'] },
{ ...REPLY, sections: [{ heading: 3, items: ['줄'] }] },
{ ...REPLY, sections: [{ heading: null, items: 'x' }] },
{ ...REPLY, sections: [{ heading: null, items: ['줄', 3] }] },
{ ...REPLY, previous: { id: '2026-11-05' } },
{ ...REPLY, next: 'x' },
]) {
expect(releaseNoteOf(reply), JSON.stringify(reply)).toBeNull()
}
})
it('releaseNoteSummaryOf — 다른 칸은 버리고 id · 날짜 · 제목만, 날짜는 실제 있는 달력 날짜여야 한다', () => {
expect(releaseNoteSummaryOf({ ...MID, sections: [] })).toEqual(MID)
expect(releaseNoteSummaryOf([])).toBeNull()
expect(releaseNoteSummaryOf({ ...MID, date: '2026-11-05T00:00:00Z' })).toBeNull()
})
})
describe('releaseNoteNeighbors (글 끝의 다른 글 줄)', () => {
it('다음 글(더 새 글)이 위 · 이전 글(더 옛 글)이 아래 — 목록과 같은 차례', () => {
expect(releaseNoteNeighbors({ next: TOP, previous: MENU })).toEqual([
{ kind: 'newer', note: TOP },
{ kind: 'older', note: MENU },
])
})
it('없는 쪽은 줄이 없다 — 맨 위 글은 이전 글만, 맨 아래 글은 다음 글만, 글이 하나뿐이면 없음', () => {
expect(releaseNoteNeighbors({ next: null, previous: MID })).toEqual([{ kind: 'older', note: MID }])
expect(releaseNoteNeighbors({ next: MID, previous: null })).toEqual([{ kind: 'newer', note: MID }])
expect(releaseNoteNeighbors({ next: null, previous: null })).toEqual([])
})
})
+115
View File
@@ -0,0 +1,115 @@
// 업데이트 내역 — 응답 읽기 · 글 주소 · 다른 글 줄 (순수 함수, 계약 7절 "업데이트 내역")
// 응답은 계약의 모양 그대로일 때만 읽는다 — 줄 · 묶음 · 다른 글 하나라도 모양이 틀리면 null(받지 못한 것 — SYS_001)로 본다.
// 앱(flutter features/settings/data/models/release_note.dart 의 tryParse)과 같은 규칙이다 — 한쪽을 고치면 둘 다
import type { PageInfo, PaginatedResult } from '@/shared/types/pagination'
import type { ReleaseNote, ReleaseNoteSection, ReleaseNoteSummary } from '@/shared/types/release-notes'
import { isCalendarDate } from '@/shared/utils/format'
/** 글 id 의 길이 한도 — 서버의 경로 검사와 같은 값 */
export const RELEASE_NOTE_ID_MAX_LENGTH = 32
/** 글 id — 파일 이름 "2026-11-18" · 같은 날 둘째부터 "2026-11-18-2" (서버가 받는 꼴을 모두 받는 넓이) */
const RELEASE_NOTE_ID_PATTERN = /^\d{4}-\d{2}-\d{2}(?:-\d+)?$/
/**
* 주소의 글 id 가 글 id 꼴인지 — 아니면 묻지 않고 "없는 글"로 본다.
* 경로 조각 "." · ".." 은 브라우저가 주소를 다듬으며 다른 경로(목록 · /api)로 바꿔 보내므로 꼴부터 거른다
*/
export function isReleaseNoteId(value: string): boolean {
return value.length <= RELEASE_NOTE_ID_MAX_LENGTH && RELEASE_NOTE_ID_PATTERN.test(value)
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
/** 값이 없음(null · 칸 없음)인지 */
function isAbsent(value: unknown): value is null | undefined {
return value === null || value === undefined
}
/** 목록의 한 줄 · 다른 글 — id(빈 글 아님) · 날짜(실제 있는 달력 날짜 "YYYY-MM-DD") · 제목이 모양에 맞지 않으면 null */
export function releaseNoteSummaryOf(value: unknown): ReleaseNoteSummary | null {
if (!isRecord(value)) return null
const { id, date, title } = value
if (typeof id !== 'string' || id === '' || typeof title !== 'string') return null
if (typeof date !== 'string' || !isCalendarDate(date)) return null
return { id, date, title }
}
/** pageInfo — hasNext 가 참 · 거짓이 아니거나 커서 · 수의 모양이 틀리면 null. 다음 쪽은 커서가 있어야 받을 수 있다 */
function pageInfoOf(value: unknown): PageInfo | null {
if (!isRecord(value)) return null
const { hasNext, nextCursor, total } = value
if (typeof hasNext !== 'boolean') return null
if (!isAbsent(nextCursor) && typeof nextCursor !== 'string') return null
if (!isAbsent(total) && typeof total !== 'number') return null
const cursor = typeof nextCursor === 'string' && nextCursor !== '' ? nextCursor : null
return { hasNext: hasNext && cursor !== null, nextCursor: cursor, total: typeof total === 'number' ? total : null }
}
/** 목록 응답 { items, pageInfo } — 모양이 틀리면(줄 하나라도) null */
export function releaseNotePageOf(reply: unknown): PaginatedResult<ReleaseNoteSummary> | null {
if (!isRecord(reply) || !Array.isArray(reply.items)) return null
const pageInfo = pageInfoOf(reply.pageInfo)
if (!pageInfo) return null
const items: ReleaseNoteSummary[] = []
for (const item of reply.items as unknown[]) {
const summary = releaseNoteSummaryOf(item)
if (!summary) return null
items.push(summary)
}
return { items, pageInfo }
}
/** 묶음 — 머리는 글 또는 없음(빈 머리는 없음으로), 줄은 글의 배열. 모양이 틀리면 null */
function sectionOf(value: unknown): ReleaseNoteSection | null {
if (!isRecord(value)) return null
const { heading, items } = value
if (!isAbsent(heading) && typeof heading !== 'string') return null
if (!Array.isArray(items)) return null
const lines: string[] = []
for (const item of items as unknown[]) {
if (typeof item !== 'string') return null
lines.push(item)
}
return { heading: typeof heading === 'string' && heading !== '' ? heading : null, items: lines }
}
/** 다른 글 칸 — 없음(null · 칸 없음)은 null, 모양이 틀리면 undefined */
function neighborOf(value: unknown): ReleaseNoteSummary | null | undefined {
if (isAbsent(value)) return null
return releaseNoteSummaryOf(value) ?? undefined
}
/** 글 응답 — 모양이 틀리면(묶음 · 다른 글 하나라도) null. previous · next 칸이 없으면 없는 것(null)으로 본다 */
export function releaseNoteOf(reply: unknown): ReleaseNote | null {
const summary = releaseNoteSummaryOf(reply)
if (!summary || !isRecord(reply) || !Array.isArray(reply.sections)) return null
const sections: ReleaseNoteSection[] = []
for (const raw of reply.sections as unknown[]) {
const section = sectionOf(raw)
if (!section) return null
sections.push(section)
}
const previous = neighborOf(reply.previous)
const next = neighborOf(reply.next)
if (previous === undefined || next === undefined) return null
return { ...summary, sections, previous, next }
}
/** 다른 글 줄의 종류 — newer: 바로 더 새 글(다음 글) · older: 바로 더 옛 글(이전 글) */
export type ReleaseNoteNeighborKind = 'newer' | 'older'
export interface ReleaseNoteNeighbor {
kind: ReleaseNoteNeighborKind
note: ReleaseNoteSummary
}
/** 글 끝의 다른 글 줄 — 다음 글(더 새 글)이 위 · 이전 글(더 옛 글)이 아래, 목록과 같은 차례. 없는 쪽은 줄이 없다 */
export function releaseNoteNeighbors(note: Pick<ReleaseNote, 'previous' | 'next'>): ReleaseNoteNeighbor[] {
const rows: ReleaseNoteNeighbor[] = []
if (note.next) rows.push({ kind: 'newer', note: note.next })
if (note.previous) rows.push({ kind: 'older', note: note.previous })
return rows
}
@@ -0,0 +1,285 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import type { ReleaseNote, ReleaseNoteSummary } from '@/shared/types/release-notes'
import { useAuthStore } from '@/stores/auth.store'
import { useToastStore } from '@/stores/toast.store'
import {
fail,
flushPromises,
installMockApi,
ok,
restoreApiAdapter,
type MockResult,
} from '@/test-utils/mock-api'
import { useReleaseNotesStore } from './release-notes.store'
// 업데이트 내역 스토어 — 목록(20개씩 · [더 보기]) · 글 (계약 7절 "업데이트 내역")
// 처음 받기 · 글은 알림을 끄고 화면이 칸 자리에 그린다. [더 보기]의 실패는 인터셉터가 알리고 받은 줄은 그대로.
// 세션이 바뀐 뒤 늦게 온 응답은 쓰지 않는다
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
function summary(day: number, title = `${day}일의 업데이트`): ReleaseNoteSummary {
const date = `2026-11-${String(day).padStart(2, '0')}`
return { id: date, date, title }
}
const FIRST_PAGE = Array.from({ length: 20 }, (_, index) => summary(30 - index))
const SECOND_PAGE = [summary(10), summary(9)]
const NOTE: ReleaseNote = {
...summary(18, '메일 검색이 빨라지고 휴대폰 앱이 나왔습니다'),
sections: [{ heading: '휴대폰 앱', items: ['안드로이드 휴대폰 앱이 나왔습니다. 웹메일의 [설정] → [휴대폰 앱]에서 받을 수 있습니다.'] }],
previous: summary(5),
next: null,
}
/** 응답을 손으로 보내는 요청 — 늦게 온 응답을 흉내 낸다 */
function deferredReply() {
let resolve: (result: MockResult) => void = () => {}
const promise = new Promise<MockResult>((settle) => {
resolve = settle
})
return { promise, resolve }
}
describe('release-notes.store', () => {
/** 같은 코드의 알림은 3초 안에 다시 띄우지 않는다(useApi) — 시험마다 시계를 1분씩 넘겨 앞 시험의 알림에 묻히지 않게 */
let clock = Date.parse('2026-11-10T09:00:00Z')
beforeEach(() => {
vi.useFakeTimers({ toFake: ['Date'] })
clock += 60_000
vi.setSystemTime(clock)
setActivePinia(createPinia())
useAuthStore().startSession(USER)
})
afterEach(() => {
useToastStore().clear()
restoreApiAdapter()
vi.useRealTimers()
})
describe('목록', () => {
it('첫 쪽 20개를 알림을 끈 채 받는다 — 다음 쪽이 있으면 hasMore', async () => {
const requests = installMockApi(() =>
ok({ items: FIRST_PAGE, pageInfo: { hasNext: true, nextCursor: 'c20', total: 22 } }),
)
const store = useReleaseNotesStore()
const loading = store.loadList()
expect(store.listStatus).toBe('loading')
await loading
expect(requests.map((request) => `${request.method} ${request.url}`)).toEqual(['GET /release-notes'])
expect(requests[0]?.params).toEqual({ limit: 20 })
expect(requests[0]?.config.silent).toBe(true)
expect(store.listStatus).toBe('ready')
expect(store.notes).toEqual(FIRST_PAGE)
expect(store.hasMore).toBe(true)
})
it('받지 못하면 error — 사전 문구 · 문의 코드를 상태에 담고 알리지 않는다', async () => {
installMockApi(() => fail(503, 'SYS_001'))
const store = useReleaseNotesStore()
await store.loadList()
expect(store.listStatus).toBe('error')
expect(store.listError?.code).toBe('SYS_001')
expect(store.listError?.message).toBe(ERROR_CODE_MESSAGES.SYS_001)
expect(store.listError?.traceId).toBe('3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d')
expect(store.notes).toEqual([])
expect(useToastStore().toasts).toEqual([])
})
it('[더 보기] — 받은 쪽 다음부터 이어 붙이고, 새로 붙은 첫 줄의 자리를 돌려준다(이미 받은 글은 다시 넣지 않는다)', async () => {
const requests = installMockApi((request) =>
request.params.cursor === 'c20'
? ok({ items: [FIRST_PAGE[19], ...SECOND_PAGE], pageInfo: { hasNext: false, nextCursor: null, total: 22 } })
: ok({ items: FIRST_PAGE, pageInfo: { hasNext: true, nextCursor: 'c20', total: 22 } }),
)
const store = useReleaseNotesStore()
await store.loadList()
const more = store.loadMore()
expect(store.loadingMore).toBe(true)
expect(await store.loadMore()).toBeNull()
expect(await more).toBe(20)
expect(requests.map((request) => request.params)).toEqual([{ limit: 20 }, { cursor: 'c20', limit: 20 }])
expect(requests[1]?.config.silent).toBeUndefined()
expect(store.notes.map((note) => note.id)).toEqual([...FIRST_PAGE, ...SECOND_PAGE].map((note) => note.id))
expect(store.loadingMore).toBe(false)
expect(store.hasMore).toBe(false)
})
it('[더 보기]를 받지 못하면 알림(인터셉터)으로 알리고 받은 줄 · 다음 쪽은 그대로', async () => {
installMockApi((request) =>
request.params.cursor === 'c20'
? fail(503, 'SYS_001')
: ok({ items: FIRST_PAGE, pageInfo: { hasNext: true, nextCursor: 'c20', total: 22 } }),
)
const store = useReleaseNotesStore()
await store.loadList()
expect(await store.loadMore()).toBeNull()
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([ERROR_CODE_MESSAGES.SYS_001])
expect(store.notes).toEqual(FIRST_PAGE)
expect(store.hasMore).toBe(true)
expect(store.loadingMore).toBe(false)
})
it('처음 목록 응답의 모양이 틀리면 받지 못함(SYS_001) — 알림 없이 화면이 그린다', async () => {
installMockApi(() => ok({ items: [...FIRST_PAGE, { id: 3 }], pageInfo: { hasNext: false, nextCursor: null, total: 21 } }))
const store = useReleaseNotesStore()
await store.loadList()
expect(store.listStatus).toBe('error')
expect(store.listError).toEqual({ code: 'SYS_001', message: ERROR_CODE_MESSAGES.SYS_001 })
expect(store.notes).toEqual([])
expect(useToastStore().toasts).toEqual([])
})
it('[더 보기] 응답의 모양이 틀리면 SYS_001 로 알리고 받은 줄 · [더 보기]는 그대로 (앱과 같다)', async () => {
installMockApi((request) =>
request.params.cursor === 'c20'
? ok({ items: SECOND_PAGE })
: ok({ items: FIRST_PAGE, pageInfo: { hasNext: true, nextCursor: 'c20', total: 22 } }),
)
const store = useReleaseNotesStore()
await store.loadList()
expect(await store.loadMore()).toBeNull()
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([ERROR_CODE_MESSAGES.SYS_001])
expect(store.notes).toEqual(FIRST_PAGE)
expect(store.hasMore).toBe(true)
expect(store.loadingMore).toBe(false)
})
it('세션이 바뀐 뒤 늦게 온 목록은 쓰지 않고, 받아 둔 값도 지운다', async () => {
const reply = deferredReply()
installMockApi(() => reply.promise)
const store = useReleaseNotesStore()
const loading = store.loadList()
useAuthStore().clearAuth()
useAuthStore().startSession({ email: 'doyun.lee@arasystem.it.kr', name: '이도윤' })
await flushPromises()
reply.resolve(ok({ items: FIRST_PAGE, pageInfo: { hasNext: true, nextCursor: 'c20', total: 22 } }))
await loading
expect(store.listStatus).toBe('idle')
expect(store.notes).toEqual([])
expect(store.pageInfo).toBeNull()
})
it('세션이 바뀐 뒤 늦게 온 [더 보기]도 붙이지 않는다', async () => {
const reply = deferredReply()
installMockApi((request) =>
request.params.cursor === 'c20'
? reply.promise
: ok({ items: FIRST_PAGE, pageInfo: { hasNext: true, nextCursor: 'c20', total: 22 } }),
)
const store = useReleaseNotesStore()
await store.loadList()
const more = store.loadMore()
useAuthStore().clearAuth()
await flushPromises()
reply.resolve(ok({ items: SECOND_PAGE, pageInfo: { hasNext: false, nextCursor: null, total: 22 } }))
expect(await more).toBeNull()
expect(store.notes).toEqual([])
expect(store.loadingMore).toBe(false)
})
})
describe('글', () => {
it('글 하나를 알림을 끈 채 받는다 — 경로의 id 는 인코딩한다', async () => {
const requests = installMockApi(() => ok(NOTE))
const store = useReleaseNotesStore()
const loading = store.loadNote('2026-11-18')
expect(store.noteStatus).toBe('loading')
await loading
expect(requests.map((request) => `${request.method} ${request.url}`)).toEqual(['GET /release-notes/2026-11-18'])
expect(requests[0]?.config.silent).toBe(true)
expect(store.noteStatus).toBe('ready')
expect(store.note).toEqual(NOTE)
})
it('없는 글(404 RES_001)은 missing · 그 밖의 실패는 error — 둘 다 알리지 않는다', async () => {
installMockApi((request) => (request.url.endsWith('2026-11-01') ? fail(404, 'RES_001') : fail(502, 'SYS_001')))
const store = useReleaseNotesStore()
await store.loadNote('2026-11-01')
expect(store.noteStatus).toBe('missing')
expect(store.noteError?.code).toBe('RES_001')
expect(store.note).toBeNull()
await store.loadNote('2026-11-02')
expect(store.noteStatus).toBe('error')
expect(store.noteError?.code).toBe('SYS_001')
expect(useToastStore().toasts).toEqual([])
})
it('글 id 꼴이 아닌 주소(".." 등)는 묻지 않고 없는 글 — 사전 문구 그대로', async () => {
const requests = installMockApi(() => ok(NOTE))
const store = useReleaseNotesStore()
await store.loadNote('..')
expect(requests).toEqual([])
expect(store.noteStatus).toBe('missing')
expect(store.noteError).toEqual({ code: 'RES_001', message: ERROR_CODE_MESSAGES.RES_001 })
})
it('응답의 모양이 틀리면(제목이 없다) 받지 못한 것으로 — SYS_001 사전 문구, 알림 없음', async () => {
installMockApi(() => ok({ id: '2026-11-18', date: '2026-11-18' }))
const store = useReleaseNotesStore()
await store.loadNote('2026-11-18')
expect(store.noteStatus).toBe('error')
expect(store.noteError).toEqual({ code: 'SYS_001', message: ERROR_CODE_MESSAGES.SYS_001 })
expect(useToastStore().toasts).toEqual([])
})
it('다른 글을 받기 시작하면 앞 글의 늦은 응답은 쓰지 않는다', async () => {
const first = deferredReply()
installMockApi((request) => (request.url.endsWith('2026-11-18') ? first.promise : ok({ ...NOTE, ...summary(5) })))
const store = useReleaseNotesStore()
const stale = store.loadNote('2026-11-18')
await store.loadNote('2026-11-05')
first.resolve(ok(NOTE))
await stale
expect(store.noteStatus).toBe('ready')
expect(store.note?.id).toBe('2026-11-05')
})
it('세션이 바뀐 뒤 늦게 온 글은 쓰지 않는다', async () => {
const reply = deferredReply()
installMockApi(() => reply.promise)
const store = useReleaseNotesStore()
const loading = store.loadNote('2026-11-18')
useAuthStore().expireSession()
await flushPromises()
reply.resolve(ok(NOTE))
await loading
expect(store.noteStatus).toBe('idle')
expect(store.note).toBeNull()
})
})
})
+159
View File
@@ -0,0 +1,159 @@
import { computed, ref, shallowRef, watch } from 'vue'
import { defineStore } from 'pinia'
import { getErrorMessage, toApiError, type ApiError } from '@/composables/useApi'
import { useReleaseNotes } from '@/composables/useReleaseNotes'
import { DEFAULT_PAGE_SIZE, type PageInfo } from '@/shared/types/pagination'
import type { ReleaseNote, ReleaseNoteSummary } from '@/shared/types/release-notes'
import { isReleaseNoteId } from '@/shared/utils/release-notes'
import { useAuthStore } from '@/stores/auth.store'
/** 목록 — idle(받기 전) · loading · ready · error(받지 못함 — 화면이 사전 문구와 [다시 시도]) */
export type ReleaseNoteListStatus = 'idle' | 'loading' | 'ready' | 'error'
/** 글 — idle · loading · ready · missing(없는 글 — 404 RES_001 · 글 id 꼴이 아닌 주소) · error(그 밖의 실패) */
export type ReleaseNoteStatus = 'idle' | 'loading' | 'ready' | 'missing' | 'error'
// 업데이트 내역 스토어 — 설정 › 정보 › 업데이트 내역의 목록과 글 (계약 7절 · 디자인 README "업데이트 내역")
// - 목록 · 글 화면은 들어올 때마다 새로 받는다(운영자가 배포하며 글을 더하거나 고친다). 받는 동안은 막대 자리만 — 지난번 값을 잠깐 그렸다 바꾸지 않는다
// - 처음 받기 · 글은 알림을 끄고(silent) 화면이 칸 자리에 그린다(받지 못함 · 없는 글). [더 보기]의 실패는 알리고(인터셉터 —
// 응답의 모양이 틀린 것은 useReleaseNotes 가 SYS_001 로) 받은 줄 · [더 보기]는 그대로 둔다
// - 늦게 온 응답은 쓰지 않는다 — 다시 받기(seq)가 시작됐거나 세션 세대(authStore.generation — 로그아웃 · 만료 · 다른 계정 로그인)가 바뀌었으면 버린다.
// 세션이 바뀌면 받아 둔 값도 지운다
export const useReleaseNotesStore = defineStore('release-notes', () => {
const api = useReleaseNotes()
const authStore = useAuthStore()
// ── 목록 ─────────────
const notes = ref<ReleaseNoteSummary[]>([])
const pageInfo = shallowRef<PageInfo | null>(null)
const listStatus = ref<ReleaseNoteListStatus>('idle')
const listError = ref<ApiError | null>(null)
/** [더 보기]로 다음 쪽을 받는 중 — 그 단추만 바쁨 */
const loadingMore = ref<boolean>(false)
let listSeq = 0
/** 다음 쪽이 있다 — [더 보기]를 둔다 */
const hasMore = computed<boolean>(
() => listStatus.value === 'ready' && pageInfo.value?.hasNext === true && pageInfo.value.nextCursor !== null,
)
/** 첫 쪽부터 새로 받는다 — 결과는 상태로만 남긴다(던지지 않는다) */
async function loadList(): Promise<void> {
listSeq += 1
const current = listSeq
const session = authStore.generation
const stale = () => current !== listSeq || session !== authStore.generation
notes.value = []
pageInfo.value = null
listStatus.value = 'loading'
listError.value = null
loadingMore.value = false
try {
const page = await api.listReleaseNotes({ limit: DEFAULT_PAGE_SIZE }, { silent: true })
if (stale()) return
notes.value = page.items
pageInfo.value = page.pageInfo
listStatus.value = 'ready'
} catch (caught) {
if (stale()) return
listStatus.value = 'error'
listError.value = toApiError(caught)
}
}
/**
* [더 보기] — 받은 쪽 다음부터 이어 받는다(이미 받은 글은 다시 넣지 않는다).
* @returns 새로 붙은 첫 줄의 자리 — 붙은 줄이 없거나 · 받지 못했거나 · 늦게 온 응답이면 null. 실패는 이미 알렸다(받은 줄 · 다음 쪽 커서는 그대로)
*/
async function loadMore(): Promise<number | null> {
const cursor = pageInfo.value?.nextCursor
if (loadingMore.value || !hasMore.value || !cursor) return null
const current = listSeq
const session = authStore.generation
const stale = () => current !== listSeq || session !== authStore.generation
loadingMore.value = true
try {
const page = await api.listReleaseNotes({ cursor, limit: DEFAULT_PAGE_SIZE })
if (stale()) return null
const known = new Set(notes.value.map((note) => note.id))
const added = page.items.filter((note) => !known.has(note.id))
const firstAdded = added.length > 0 ? notes.value.length : null
notes.value = [...notes.value, ...added]
pageInfo.value = page.pageInfo
return firstAdded
} catch {
// 인터셉터 · useReleaseNotes(모양이 틀린 응답)가 알렸다 — 받은 줄은 그대로
return null
} finally {
if (current === listSeq) loadingMore.value = false
}
}
// ── 글 ─────────────
const note = shallowRef<ReleaseNote | null>(null)
const noteStatus = ref<ReleaseNoteStatus>('idle')
const noteError = ref<ApiError | null>(null)
let noteSeq = 0
/** 글 하나를 새로 받는다 — 글 id 꼴이 아닌 주소는 묻지 않고 없는 글로 둔다. 결과는 상태로만 남긴다 */
async function loadNote(id: string): Promise<void> {
noteSeq += 1
const current = noteSeq
const session = authStore.generation
const stale = () => current !== noteSeq || session !== authStore.generation
note.value = null
noteError.value = null
if (!isReleaseNoteId(id)) {
noteStatus.value = 'missing'
noteError.value = { code: 'RES_001', message: getErrorMessage('RES_001') }
return
}
noteStatus.value = 'loading'
try {
const loaded = await api.getReleaseNote(id, { silent: true })
if (stale()) return
note.value = loaded
noteStatus.value = 'ready'
} catch (caught) {
if (stale()) return
const failure = toApiError(caught)
noteError.value = failure
noteStatus.value = failure.code === 'RES_001' ? 'missing' : 'error'
}
}
function reset() {
listSeq += 1
noteSeq += 1
notes.value = []
pageInfo.value = null
listStatus.value = 'idle'
listError.value = null
loadingMore.value = false
note.value = null
noteStatus.value = 'idle'
noteError.value = null
}
// 로그아웃 · 세션 만료 · 다른 계정 로그인 — 앞 세션의 응답 · 값이 남지 않게
watch(
() => authStore.generation,
() => reset(),
)
return {
notes,
pageInfo,
listStatus,
listError,
loadingMore,
hasMore,
loadList,
loadMore,
note,
noteStatus,
noteError,
loadNote,
reset,
}
})
+12
View File
@@ -252,6 +252,18 @@
--size-editor-size-button: 3.5rem; /* 56px — 글자 크기 단추 */
--size-editor-toolbar: 3.25rem; /* 52px — 툴바 높이 (위아래 4) */
--size-editor-body-min: 18.75rem; /* 300px — 본문 편집 영역의 최소 높이 (편집기를 불러오는 동안의 자리도 같다) */
/*
* 200px — 넓은 화면(70rem 이상 — 쓰기 칸이 화면 높이에 묶여 따로 스크롤한다)에서 쓰기 칸이 화면보다 길어지려 할 때 본문 편집 영역이 줄어드는 한계.
* 자리가 넉넉하면 지금처럼 300 이상으로 늘어나고, 받는 사람이 많은 낮은 화면에서만 이 값까지 줄어 쓰기 칸 전체가 화면 안에 든다 —
* 그 밑으로는 쓰기 칸이 스크롤한다 (2026-11-10 사용자 보고 — 바깥 스크롤 + 본문 스크롤의 이중 스크롤)
*/
--size-editor-body-min-fit: 12.5rem;
/*
* 96px — 받는 사람 · 참조 · 숨은참조 칸(칩 + 입력)이 보이는 최대 높이: 칩 28 · 입력 34 · 줄 사이 4 로 맨 위에서는 칩 세 줄,
* 맨 아래(입력 자리)에서는 칩 두 줄 + 입력 줄이 줄 사이에서 끊긴다 — 반쯤 잘린 칩이 보이지 않게 (칸의 위 여백 4 는 RecipientField 가 더한다).
* 넘으면 그 칸 안에서 스크롤한다 (2026-11-10 사용자 보고 — 전 직원을 숨은참조에 넣자 칸이 늘어 [보내기]가 화면 밖으로 숨었다)
*/
--size-recipient-chips-max: 6rem;
--editor-toolbar-inline: 1.25rem; /* 20px — 툴바 좌우 여백. 버튼 안 12 를 더하면 첫 아이콘이 32px 선 */
--color-editor-pressed-hover: #d5ebed; /* 켜진 버튼에 마우스를 올렸을 때 */
--color-quote-bar: #d0d5dd; /* 원문 인용 막대 — 포커스 선(브랜드색)과 겹치지 않게 회색 */

Some files were not shown because too many files have changed in this diff Show More