generated from ttipo/project-base
Compare commits
20 Commits
19dbb2babb
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b8a5114af9 | |||
| 198ac8f4f2 | |||
| b644e8b104 | |||
| bd916ccbed | |||
| 94ec659b78 | |||
| 487760bd9b | |||
| 37ffcfca2c | |||
| f4a85fda27 | |||
| 0ff1ec46af | |||
| f3586abcd7 | |||
| 6eba013416 | |||
| f0e9a50b01 | |||
| 5ce614cc0c | |||
| def96e833b | |||
| 1d01130c9d | |||
| 1b3f9ba7fe | |||
| cd958f3bf4 | |||
| abe4c1364c | |||
| 1962f07d79 | |||
| d6b804ba83 |
@@ -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)` 처럼 글자 코드로 만든다
|
||||
|
||||
## 네이밍 규약
|
||||
|
||||
|
||||
@@ -106,7 +106,7 @@ sudo docker compose --env-file .env.production logs -f backend
|
||||
`nginx-api-errors.conf`(이름 있는 오류 location — `@api_not_found` 404 `RES_001` 포함). 공개 화면 `/s/` 는 `no-referrer`(주소에 토큰) · `noindex` · `no-cache`.
|
||||
- 받기 `GET /api/public-links/{token}/files/…` · `GET /api/messages/{id}/large-files/…` 는 SSE 처럼 버퍼링 · 압축을 끄고 `X-Accel-Buffering` 을 넘긴다
|
||||
(기본값이면 받는 사람마다 1GB 까지 임시 파일에 받아 둔다). Range · If-Range 는 그대로 — 206 · 416 은 백엔드가 만든다.
|
||||
**휴대폰 앱(APK) 받기 `GET /api/app-releases/android/latest/file`**(2026-11-08 — 계약 1절 "휴대폰 앱 받기", 포털 · 로그인한 직원, 판마다 30MB 남짓)도 같은 줄들이다 —
|
||||
**휴대폰 앱(APK) 받기 `GET /api/app-releases/android/latest/file`**(2026-11-08 — 계약 1절 "휴대폰 앱 받기", 포털 · 로그인한 직원, 판마다 65MB 안팎 — 기기 종류 셋(ABI)이 한 파일에 든다)도 같은 줄들이다 —
|
||||
머리글을 대용량 받기와 같은 도우미(`backend/src/shared/utils/download-headers.ts` — `X-Accel-Buffering: no`)가 만든다. 정보 `GET /api/app-releases/android/latest`(JSON)는 받기 location 에 들지 않고,
|
||||
공개 내려받기 server 에서는 그 밖의 `/api` 처럼 404 `RES_001` 이다.
|
||||
머리글 버퍼는 `proxy_buffer_size 16k` + `proxy_busy_buffers_size 16k`(둘이 함께 — 하나만이면 `nginx -t` 가 실패) — 한글 255자 이름이면 머리글이 3.5KB 라 기본 4KB 에 아슬아슬하다.
|
||||
@@ -223,7 +223,7 @@ sudo docker compose --env-file .env.production logs -f backend
|
||||
- 업로드 중 임시 디스크: 백엔드는 첨부를 `/tmp/arasystem-mail-uploads`(컨테이너 쓰기 계층)에, nginx 는 요청 본문을 자기 임시 폴더에 받는다.
|
||||
필요한 공간은 동시 업로드 수 × 20MB 정도. backend 를 `read_only` 로 바꾸면 그 경로에 **tmpfs 가 아닌 볼륨**을 붙인다 (tmpfs 는 메모리 한도에 잡힌다).
|
||||
- **업로드 파일은 named volume `<PROJECT_NAME>_uploads` 에 쌓인다** (`/api/files`, 메일 서명 그림은 그 안의 `mail-signatures/` — 서명 표와 함께 백업한다,
|
||||
휴대폰 앱 APK 는 그 안의 `app-releases/` — 판마다 30MB 남짓 · 지우는 명령이 없어 올린 판이 모두 남는다 · 매일 백업에 든다). 호스트 디렉터리(`./data/uploads`)를 쓰지 않는 이유 —
|
||||
휴대폰 앱 APK 는 그 안의 `app-releases/` — 판마다 65MB 안팎 · 지우는 명령이 없어 올린 판이 모두 남는다 · 매일 백업에 든다). 호스트 디렉터리(`./data/uploads`)를 쓰지 않는 이유 —
|
||||
`sudo docker compose` 가 없는 디렉터리를 root 소유로 만들어 backend(node, UID 1000)가 쓰지 못한다. 새 named volume 은 이미지의
|
||||
`/app/uploads`(node 소유) 소유자를 물려받는다 (2026-10-27 운영 이미지로 확인). 개발만 `./data/uploads` 바인드 마운트다.
|
||||
백업은 매일 백업(`ops/` — 아래 "백업 · 감시")에 든다.
|
||||
|
||||
@@ -25,9 +25,9 @@ description: Flutter 모바일 앱(flutter/) 개발 규칙 — Dart Sound Null S
|
||||
| 앱 로그인 저장소 | `lib/features/auth/data/` | 계약 1절 "앱 로그인"(`/auth/app/login` · `refresh` · `logout`(리프레시 토큰 + Bearer) + Bearer `me`, 인터셉터 줄 밖의 세션 확인 `readSessionDirect`). 실패는 늘 `DioException`(`error` 에 `ApiException` — 응답 모양이 틀리면 `SYS_001`, `apiFailure()`). 모델은 손으로 쓴다(`AppLoginResult` · `AuthSessionInfo` · `SessionRestriction` — 모르는 제한은 `unknown`). 로그인 화면이 칸에 보이는 실패(`kLoginInlineCodes`)는 알리지 않는다 |
|
||||
| **로그인 화면 (네 단계 — `App1Login`)** | `lib/features/auth/presentation/pages/`(login · login_code · password_change · two_factor_setup · restricted_login) · `widgets/`(`login_frame` · `login_fields` · `login_parts`) · 글 `core/constants/login_texts.dart`(제목은 `screen_titles.dart`) · `data/`(`two_factor_repository` · `support_contact_repository` · `authenticator_launcher`) | 노티파이어의 제한 단계 API(`attempt` · `passedCodeStep` · `heldLoginListenable` · `changePassword` · `enableTwoFactor` · `finishRestrictedStep` → `RestrictedStepResult { done, needsPassword, stale }`). 로그인 비밀번호는 두 제한 단계에서만 10분 쥔다(모르는 제한 · 제한이 풀리면 지움). 칸 · 상자에 보이는 코드만 알림을 끈다(`kLoginInlineCodes` · `kLoginCodeInlineCodes` · `kPasswordChangeInlineCodes` · `kTwoFactorSetupInlineCodes` — 앱 디자인 README 의 표), `AUTH_002` 는 스낵바 없이 로그인 화면 상자만. 2단계 키 · otpauth 주소는 그 화면 State 에만. **url_launcher**: `canLaunchUrl` 은 쓰지 않는다(debug 판이 주소째 로그를 남긴다) — `launchUrl(externalNonBrowserApplication)` 만, 실패 예외에 주소가 들어 있어 삼킨다, `<queries>` 는 필요 없다. 한글 줄바꿈은 `shared/widgets/keep_all_text.dart`(시험은 `findCopy`). 보안 설정 요청 한도 `kSecuritySettingTimeout` 30초 |
|
||||
| **메일 목록 (`App1MailList`)** · 앱 틀 | `lib/features/mail/` — data(models · `mail_repository.dart`) · domain(`mailbox_tree` · `mail_list_merge` · `mail_bulk` — 순수 함수) · presentation(`pages/mail_list_page.dart` · providers · widgets), 틀 `core/router/app_shell.dart` · `shell_bar.dart`, `core/utils/`(`app_foreground` · `accessible_navigation` · `korean_format`), `shared/widgets/confirm_dialog.dart`, 시험 도구 `test/support/mail_fakes.dart` · `mail_harness.dart` | **틀은 Scaffold 가 아니다**(Column + 탭 막대 — 탭 안 화면마다 자기 Scaffold 를 둔다, 없으면 스낵바 · Material 조상이 없다). 탭 막대는 `MediaQuery.removePadding(removeTop)` 으로 감싸고, 화면이 숨기려면 `ShellBarHidden`(모듈마다 센다 — 보이는지는 TickerMode). Scaffold 는 넓은 단추 자리에 안전 영역을 넘기지 않으니 Scaffold 밖에서 읽어 넘긴다. 목록 상태는 메일함마다(`mailListProvider(String?)` autoDispose), 세션이 바뀌면 `mailAccountProvider` 로 처음부터. 알림은 두 갈래 — 단추 있는 `mailToastProvider`(한 번에 하나 · 화면 낭독기 켠 동안 닫지 않음) · 단추 없는 `mailNoticeSinkProvider`(전역 스낵바). 생명주기 안에서는 프로바이더를 바꾸지 않는다(그린 뒤 · 마이크로태스크), 다른 노티파이어 인스턴스를 든 노티파이어는 세션이 바뀐 뒤 다시 읽는다. 가짜 서버는 응답이 한 프레임 안에 오므로 "받는 중이 끝나는 순간"을 기다리는 위젯을 만들지 않는다 |
|
||||
| **메일 읽기 (`App1Reader`)** | `lib/features/mail/` — domain(`mail_html`(두 번째 정화 · 중첩 비용 · CSP 문서 · 중계 그림 바꿔 넣기) · `mail_images` · `mail_links` · `compose_request` · `mail_body_notices`) · `data/`(`models/message_detail.dart` · `mail_link_launcher.dart`) · presentation(`pages/message_reader_page.dart` · `providers/message_reader_controller.dart` · `attachment_downloads.dart` · widgets `mail_body_view` · `reader_*`), 시험 도구 `test/support/fake_webview.dart`(`WebViewPlatform.instance`) · `file_fakes.dart` | **WebView 에는 `buildMailDocument` 의 문서만 넣는다**(CSP 를 맨 앞 · JS 끔 · 처음 문서 말고는 탐색을 막음 · 안드로이드는 파일 · content 접근 · 위치 · 섞인 내용 끔, 아래 틀(iframe)의 탐색은 판정을 거치지 않으니 막는 것은 정화(내용째 버림)와 CSP 다). 받은 HTML 은 서버가 정화했어도 `sanitizeMailHtml` 을 한 번 더 지난다 — package:html 은 `parse().body`(`parseFragment` 는 맨 위 형제가 많으면 제곱), 그 전에 `htmlNestingCost`(선형 · 파서보다 깊게 센다)로 한도를 보고, 바로 할지 isolate 로 보낼지는 `mailBodyBuildPlaceOf` 하나로 고른다. 중계 그림은 Dio(Bearer)로 받아 `data:` 로 넣고 토큰은 WebView 에 가지 않는다 — 문서에 싣는 양은 **나온 횟수까지** 센다(`resolveProxiedImages`). 응답은 그 요청이 바꾼 칸만 지금 요약에 덮는다(`_applyResponse`). 메일 id 마다 autoDispose 프로바이더(라우트 `ValueKey(id)`) · 받기 세대 · 본문 세대 · 세션 세대로 늦은 응답을 버린다. 목록에는 `MailChange(updated, removedIds)` 로 알린다. 쓰기 화면을 여는 값은 웹과 같은 쿼리(`replyTo` · `all` · `forward` · `draft` — `composeIntentOf`), mailto 로 채울 값은 go_router `extra` 의 `ComposePrefill` |
|
||||
| **메일 읽기 (`App1Reader`)** | `lib/features/mail/` — domain(`mail_html`(두 번째 정화 · 중첩 비용 · CSP 문서 · 중계 그림 바꿔 넣기) · `mail_images` · `mail_links` · `compose_request` · `mail_body_notices`) · `data/`(`models/message_detail.dart` · `mail_link_launcher.dart`) · presentation(`pages/message_reader_page.dart` · `providers/message_reader_controller.dart` · `attachment_downloads.dart` · widgets `mail_body_view` · `reader_*`), 시험 도구 `test/support/fake_webview.dart`(`WebViewPlatform.instance`) · `file_fakes.dart` | **WebView 에는 `buildMailDocument` 의 문서만 넣는다**(CSP 를 맨 앞 · JS 끔 · 처음 문서 말고는 탐색을 막음 · 안드로이드는 파일 · content 접근 · 위치 · 섞인 내용 끔, 아래 틀(iframe)의 탐색은 판정을 거치지 않으니 막는 것은 정화(내용째 버림)와 CSP 다). **보이는 것이 없는 본문이면 본문 칸(WebView)을 두지 않는다**(2026-11-12 — 첨부만 있는 메일에서 아래 절반이 빈 칸으로 남고 첨부가 위 절반에서 잘렸다): 판정은 정화 길이 함께 낸다(`SanitizedMailBody.hasVisibleContent` — 남긴 글 가운데 `core/utils/visible_text.dart` 의 `hasVisibleText`(공백 · 제어 문자 · Default_Ignorable(제로폭 · 방향 표시 · 한글 채움 등) · 점자 빈칸은 보이지 않음)인 글, 주소가 남은 그림(`_attributesOf` 가 src 를 맨 앞에 쓰는 모양에 기댄다 — 외부 그림은 받기 전에도), 글 없이 그려지는 `hr` · `li` · `details` · `q`. 꾸밈(style · bgcolor · border)만은 치지 않는다. 고르는 본문(`displayedHtmlBody`)은 그대로) → 상태 `bodyEmpty` → 머리의 최대 높이가 화면 전체(`kReaderHeadMaxFraction` 대신 — 그림 받는 진행 막대 자리만 뺀다 · 0 아래로 내려가지 않게), 아래 막대가 없는 메일(예약)은 머리 스크롤에 시스템 아래 여백을 더한다. 문서를 만들기 전(`document == null`)은 지금처럼 본문 자리의 진행 줄. 받은 HTML 은 서버가 정화했어도 `sanitizeMailHtml` 을 한 번 더 지난다 — package:html 은 `parse().body`(`parseFragment` 는 맨 위 형제가 많으면 제곱), 그 전에 `htmlNestingCost`(선형 · 파서보다 깊게 센다)로 한도를 보고, 바로 할지 isolate 로 보낼지는 `mailBodyBuildPlaceOf` 하나로 고른다. 중계 그림은 Dio(Bearer)로 받아 `data:` 로 넣고 토큰은 WebView 에 가지 않는다 — 문서에 싣는 양은 **나온 횟수까지** 센다(`resolveProxiedImages`). 응답은 그 요청이 바꾼 칸만 지금 요약에 덮는다(`_applyResponse`). 메일 id 마다 autoDispose 프로바이더(라우트 `ValueKey(id)`) · 받기 세대 · 본문 세대 · 세션 세대로 늦은 응답을 버린다. 목록에는 `MailChange(updated, removedIds)` 로 알린다. 쓰기 화면을 여는 값은 웹과 같은 쿼리(`replyTo` · `all` · `forward` · `draft` — `composeIntentOf`), mailto 로 채울 값은 go_router `extra` 의 `ComposePrefill` |
|
||||
| 받은 파일 · 고른 파일 · 형식 | `lib/core/files/` — `downloaded_files.dart`(`DownloadedFileStore` · `CacheDirFileStore` · `FileOpener`) · `picked_files.dart`(`PickedFileStore` · `PlatformPickedFileStore` — 채널의 `pick`) · `file_names.dart`(`safeFileName` · `displayFileName`) · `file_types.dart`(`fileOpenTypeOf` · `isBlockedFileOpen`), 안드로이드 `MainActivity.kt`(채널 `kr.it.arasystem.app/files`) · `res/xml/downloaded_file_paths.xml` · 매니페스트의 FileProvider `${applicationId}.downloads` | 모듈 공통(메일 첨부 · 나중의 파일 모듈). 자리는 `cacheDir/downloads/<무작위 16자>/<정리한 이름>` 뿐 — **짝 셋**: Dart `kDownloadedFilesFolder` · `downloaded_file_paths.xml` 의 cache-path · `MainActivity.kt` 의 `DOWNLOADS_DIR`(채널 이름도 짝). Kotlin 은 canonical 경로로 그 폴더 안인지 다시 보고 읽기 권한을 한 번만 준다. 파일 여는 플러그인(open_filex 등)은 쓰지 않는다 — 저장소 권한 · 기기 전체 경로를 더한다. 열 형식은 확장자 먼저(`package:mime`), 보낸 쪽 형식은 확장자를 모를 때만 — 형식 글 모양 검사는 짝: Kotlin `MIME_PATTERN` · `GENERIC_TYPE`. 막는 형식(웹 문서 · svg · 설치 파일 · 인증서)은 받지도 않는다. **고른 파일**(메일 쓰기 2차): 고르는 창(`ACTION_GET_CONTENT` — 권한 없음 · `*/*` · 여러 개)으로 고른 파일을 `cacheDir/uploads/<무작위 16자>/<정리한 이름>` 에 복사본으로 둔다 — **짝 둘**: Dart `kPickedFilesFolder` · `MainActivity.kt` 의 `UPLOADS_DIR`(다른 앱에 건네지 않아 FileProvider xml 에는 없다). 채널 인자 · 답의 칸 · 값도 짝이다 — Dart `PlatformPickedFileStore.pick` · `PickResult.parse` · `PickedFile.fromChannel` ↔ Kotlin `ARG_*` · `KEY_*` · `OUTCOME_*` · `STATUS_*`. Kotlin 은 `content://` 만 읽고, Dart 사전 검사와 같은 차례(개수 → 형식 → 파일당 → 합계 — `copyPicked` ↔ `precheckPickedFile`)로 넣을 파일만 복사한다 — 미리 거르기일 뿐 판정은 Dart 가 다시 한다. 확장자를 떼는 규칙도 짝(Kotlin `displayFileName` · `extensionOf` · `isDartWhitespace` ↔ Dart `displayFileName` · `extensionOf` · `trim()`). 복사본 이름은 Kotlin `safeFileName`(Dart 와 같은 규칙), 보이고 보내는 이름은 `displayFileName` 이다. 복사 스레드는 Throwable 까지 잡아 한 번만 답한다. 고르기와 모두 지우기는 차례로 돌고, 지우기는 기호 링크를 푼 경로로 복사본 폴더 바로 아래 한 칸만 지운다. `pick` 은 그 차례 안에서 `kPickTimeout`(10분) 시한을 둔다 — 넘으면 `timedOut`(Dart 에만 있는 결과)으로 끝내고 차례를 넘긴다(늦게 온 복사본은 다음 정리에). 파일 고르는 플러그인은 쓰지 않는다(권한 · 기기 전체 경로) |
|
||||
| **메일 쓰기 (`App1Compose` — 1차 · 2차)** | `lib/core/people/`(사람 고르기 — 주소 칩 · 추천 맞추기 · 그룹, 모듈 공통. 표본 표 `test/core/people/recipient_match_samples.dart` 는 웹 · 백엔드와 **세 쪽 짝**) · `features/mail/domain/`(`compose_body` 직렬화 · `compose_fit`(`buildInitialQuote`) · `mail_quote` · `compose_draft_body`(`buildDraftBody` · `draftBodyPlaceOf`) · `compose_reply` · `compose_send` · `compose_attachments`(2차 — 형식 표 · 한도 · 사전 검사) · `compose_signature` · `html_nodes`) · `data/`(`compose_models` · `compose_payload` · `repositories/compose_repository.dart`) · presentation(`pages/message_compose_page.dart` · `providers/compose_controller.dart`(화면마다 하나 — `ComposeArgs.instance`, autoDispose) · `draft_autosave.dart` · `attachment_uploads.dart`(`AttachmentUploadQueue` — 둘씩 · 취소 · 다시 올리기) · `signature_image_provider.dart` · widgets `compose_*`) · `core/utils/utf16_length.dart` · 시험 도구 `test/support/compose_fakes.dart` | 본문 모양은 계약 2절 "앱이 만드는 모양". 웹 짝(`mail-address.ts` · `recipient-match.ts` · `MAIL_HTML_STYLES` · `docToText` · `fit-draft` · `DRAFT_*` · `shouldRenewSendKey`)은 각 파일 머리말에 적었다 — 한쪽을 고치면 둘 다. **package:html 의 `text` · `querySelectorAll` · `innerHtml` · `outerHtml` 은 깊이만큼 되풀이한다** — 쓰기 화면은 `html_nodes.dart`(되풀이 없는 글 모으기 · 찾기 · 직렬화)를 쓰고, 저장본 · 원문은 읽기 화면과 같은 한도 · 문턱(`mailBodyBuildPlaceOf`)으로 그 자리 · isolate · 읽지 않음을 고른다. **화면을 떠나도 끝까지 가야 하는 일(보내기)은 기다림을 시작하기 전에 `ref.keepAlive()` 를 잡고 try/finally 로 놓는다**(2026-11-07 리뷰 — 저장을 기다리는 사이 내려가 메일이 나가지 않았다). 임시 저장은 Timer 만 쓴다(시험은 `tester.pump` 로 시간을 넘긴다). 보내기 결과는 `mailToastProvider` 의 마감 알림(목록 · 읽기 화면이 그린다). 서버와 같은 한도를 거는 칸은 `Utf16LengthLimitFormatter`(UTF-16 단위). **첨부(2차)**: 형식 표 `kMailAttachmentTypes` 는 **짝: 백엔드 `attachment.policy.ts` 의 `ATTACHMENT_TYPES`** — 시험이 그 파일을 읽어 표와 `MAX_ATTACHMENTS_PER_MESSAGE` 를 맞춰 본다 (앱 CI 는 flutter/ 가 바뀐 커밋에서만 돈다 — 백엔드 표만 고친 커밋에서는 이 시험이 돌지 않으니 함께 고친다). 사전 검사는 웹 `precheckAttachment` 의 차례, 보내기 막기는 웹 `sendBlockOf` 의 차례(가져오는 중은 올리는 중 다음). 저장소 `uploadAttachment`(multipart `file` · 확장자의 정식 형식 · 보내기 5분 · 받기 60초 · 알림 끔 — 401 뒤에는 FormData 를 복제해 복사본을 처음부터 다시 읽는다) · `getAttachmentLimits`(`GET /api/mail-quota`). 다 올리면 서버의 이름 · 크기로 싣는다(이름 255자 — 웹과 같다, 2026-11-07 리뷰). `MAIL_004` 뒤 다시 올릴 수 없는 고른 파일은 `problem: missing` 으로 막는다(웹 `markMissing`). 시험 도구 `FakePickedFiles`(`test/support/file_fakes.dart`) · `FakeComposeBackend` 의 `uploads` · `uploadGates` · `quota` · `quotaGate` · `FakeServer.readRequestBodies` |
|
||||
| **메일 쓰기 (`App1Compose` — 1차 · 2차)** | `lib/core/people/`(사람 고르기 — 주소 칩 · 추천 맞추기 · 그룹, 모듈 공통. 표본 표 `test/core/people/recipient_match_samples.dart` 는 웹 · 백엔드와 **세 쪽 짝**) · `features/mail/domain/`(`compose_body` 직렬화 · `compose_fit`(`buildInitialQuote`) · `mail_quote` · `compose_draft_body`(`buildDraftBody` · `draftBodyPlaceOf`) · `compose_reply` · `compose_send` · `compose_attachments`(2차 — 형식 표 · 한도 · 사전 검사) · `compose_signature` · `html_nodes`) · `data/`(`compose_models` · `compose_payload` · `repositories/compose_repository.dart`) · presentation(`pages/message_compose_page.dart` · `providers/compose_controller.dart`(화면마다 하나 — `ComposeArgs.instance`, autoDispose) · `draft_autosave.dart` · `attachment_uploads.dart`(`AttachmentUploadQueue` — 둘씩 · 취소 · 다시 올리기) · `signature_image_provider.dart` · widgets `compose_*`) · `core/utils/utf16_length.dart` · 시험 도구 `test/support/compose_fakes.dart` | 본문 모양은 계약 2절 "앱이 만드는 모양". 웹 짝(`mail-address.ts` · `recipient-match.ts` · `MAIL_HTML_STYLES` · `docToText` · `fit-draft` · `DRAFT_*` · `shouldRenewSendKey`)은 각 파일 머리말에 적었다 — 한쪽을 고치면 둘 다. **package:html 의 `text` · `querySelectorAll` · `innerHtml` · `outerHtml` 은 깊이만큼 되풀이한다** — 쓰기 화면은 `html_nodes.dart`(되풀이 없는 글 모으기 · 찾기 · 직렬화)를 쓰고, 저장본 · 원문은 읽기 화면과 같은 한도 · 문턱(`mailBodyBuildPlaceOf`)으로 그 자리 · isolate · 읽지 않음을 고른다. **화면을 떠나도 끝까지 가야 하는 일(보내기)은 기다림을 시작하기 전에 `ref.keepAlive()` 를 잡고 try/finally 로 놓는다**(2026-11-07 리뷰 — 저장을 기다리는 사이 내려가 메일이 나가지 않았다). 임시 저장은 Timer 만 쓴다(시험은 `tester.pump` 로 시간을 넘긴다). 보내기 결과는 `mailToastProvider` 의 마감 알림(목록 · 읽기 화면이 그린다). 서버와 같은 한도를 거는 칸은 `Utf16LengthLimitFormatter`(UTF-16 단위). **첨부(2차)**: 형식 표 `kMailAttachmentTypes` 는 **짝: 백엔드 `attachment.policy.ts` 의 `ATTACHMENT_TYPES`** — 시험이 그 파일을 읽어 표와 `MAX_ATTACHMENTS_PER_MESSAGE` 를 맞춰 본다 (앱 CI 는 flutter/ 가 바뀐 커밋에서만 돈다 — 백엔드 표만 고친 커밋에서는 이 시험이 돌지 않으니 함께 고친다). 사전 검사는 웹 `precheckAttachment` 의 차례, 보내기 막기는 웹 `sendBlockOf` 의 차례(가져오는 중은 올리는 중 다음). 저장소 `uploadAttachment`(multipart `file` · 확장자의 정식 형식 · 보내기 5분 · 받기 60초 · 알림 끔 — 401 뒤에는 FormData 를 복제해 복사본을 처음부터 다시 읽는다) · `getAttachmentLimits`(`GET /api/mail-quota`). 다 올리면 서버의 이름 · 크기로 싣는다(이름 255자 — 웹과 같다, 2026-11-07 리뷰). `MAIL_004` 뒤 다시 올릴 수 없는 고른 파일은 `problem: missing` 으로 막는다(웹 `markMissing`). 시험 도구 `FakePickedFiles`(`test/support/file_fakes.dart`) · `FakeComposeBackend` 의 `uploads` · `uploadGates` · `quota` · `quotaGate` · `FakeServer.readRequestBodies`. **칸 줄**은 `ComposeFieldRow`(`widgets/compose_recipient_field.dart`) 하나 — 칸 이름 72 · 글자 15 · 간격 6 · 여백 4 4 4 16, 칸 이름 · 오른쪽 단추는 첫 줄(`ComposeFieldRow.lineHeight` 48)의 가운데(받는 사람은 `firstLineHeight` 로 칩 줄 40 을 넘겨 칩이 여러 줄이어도 첫 칩 줄에 맞춘다), 아래 선은 `foregroundDecoration` 이라 초점으로 굵어져도 칸이 움직이지 않는다. 입력칸은 `borderlessInputDecoration`(2026-11-12 — 아래 함정). 본문 칸 최소 높이는 `composeBodyMinHeightOf`(원본 메일이 있으면 150 · 없으면 240 — 서명과 상관없다, 보드 App1Compose) |
|
||||
| **메일 검색 (`App1Search` — 1차 · `App1SearchConditions` — 2차)** | domain `search_text`(**짝: 웹 `search-match.ts` · `mail-search.ts` · 백엔드 `search-text.ts`** — 표본 표는 시험이 `backend/test/search-text-samples.ts` 의 FOLD · GRAM 을 직접 읽는다 · `test/support/ts_table.dart`) · `search_highlight`(짝: 웹 `search-highlight.ts`) · `search_conditions`(2차 — **짝: 웹 `mail-search.ts` · `mail-vacation.ts`** — 조건 다듬기 · 합친 낱말 수 · 기간 · after/before(기기 시간대 0시 → UTC, 끝 날짜는 다음 날 0시) · 강조 쿼리 · "나") · `data/models/mail_search.dart` · `mail_repository`(`searchMessages` — 응답의 `Date` 로 서버 시각 · `searchIndexStatus` · `getMessage(highlight)`) · presentation(`pages/mail_search_page` · `providers/mail_search_controller`(화면마다 — `MailSearchArgs.instance`, autoDispose) · `mail_search_state`(`waitsForMore`) · `mail_search_view` · widgets `mail_search_bar`(+ 공용 `LineBreaksToSpacesFormatter`) · `mail_search_states` · `highlighted_text` · `mail_search_conditions_sheet`(2차)) · `core/utils/js_case.dart` | 탭 안 `/mail/search`(시작한 메일함은 `extra` 의 `MailSearchStart`, 검색어는 주소에 싣지 않는다). 이어 찾기는 한 차례 `kMailSearchFillMaxRequests`(5) · 20통, 모자라면 [더 찾기]로 멈춘다. 검색 세대 + `CancelToken`(새 검색 · 화면 닫기에 끊는다), 준비 상태는 따로 세대. 결과는 `MailChange` 와 붙잡힌 줄 다시 받기(보낼 시각 + 2초)만 반영한다. **강조 위치는 그 응답의 글에만**. 읽기 키는 `MessageReaderArgs(id, highlight)`, 결과에서 연 메일은 `MailSearchOpen`. 조건(2차)은 화면 상태 `MailSearchState.conditions` 로 이어 찾기 · 새로고침 · 다시 시도 · 찾을 곳 바꾸기에 이어지고, "나"는 `mailSelfProvider`. 시트 가림막은 테마의 바닥 시트 가림막(`AppColors.backdrop` — 옮기기 시트도), 날짜 창은 `showDatePicker(barrierColor:)` · 머리 22(`datePickerTheme`). 노랑 `AppColors.searchMark` 는 **짝: 웹 `--color-search-mark` · `mail_html.dart` 의 mark**. 시험 도구는 `FakeMailBackend` 의 `defaultSearch`(받은 시각 내림차순 · `searchCursorAfter` 는 그 줄 다음부터) · `move` · `searchResponder` · `searchGate` · `searchHighlights` · `highlightDetails` · `searchIndex` |
|
||||
| HTTP 클라이언트 | `lib/core/api/dio_client.dart` · `dio_provider.dart` | Dio 둘: 화면용(인터셉터 줄 `ResponseInterceptor` → `AuthInterceptor` → `ErrorNotifyInterceptor`)과 갱신 · 다시 보내기 · 로그인 없는 앱 설정용(언래핑만 — `publicDioProvider`. 인증 인터셉터는 요청을 한 줄로 세워 앞 요청의 미리 갱신을 기다리게 하므로, 앱을 띄우자마자 묻는 앱 설정은 줄 밖으로 보낸다). baseUrl=dotenv `API_URL`(`api_config.dart`), User-Agent `ARASYSTEM-App/<판> (Android <판>; <기종>)`(`user_agent.dart`). `LogInterceptor` 는 넣지 않는다(비밀번호 · 토큰). 시험은 `httpClientAdapterProvider` 로 가짜 서버를 끼운다 |
|
||||
| 언래핑 · 코드 정규화 | `lib/core/api/response_interceptor.dart` · `api_error_mapper.dart` | `{success,data}→data`(스트림 · 바이트는 그대로 — 그 **실패** 본문은 64KB 까지 읽어 `error.code` 를 꺼낸다(`decodeRawErrorBody`). 스트림을 받는 도중의 실패는 인터셉터를 거치지 않아 부르는 쪽이 `SYS_001` 로 알린다(`DownloadInterrupted`). 비동기 처리기는 무슨 일이 있어도 `handler.next` 로 끝낸다 — 던지면 요청이 매달린다), 200 + `success:false` 도 실패, 실패는 모두 `DioException.error` 에 `ApiException`. 상태 → 코드 폴백(웹 `resolveCodeByStatus` 와 같다) |
|
||||
@@ -36,11 +36,13 @@ description: Flutter 모바일 앱(flutter/) 개발 규칙 — Dart Sound Null S
|
||||
| **인증 인터셉터** | `lib/core/api/auth_interceptor.dart` · `token_refresher.dart` | `QueuedInterceptor`. Bearer 붙이기 · 액세스 토큰이 없거나 30초 안에 끝나면 미리 갱신 · 401 이면 갱신 한 번 → 원래 요청 한 번 다시(로그인 · 갱신 · 로그아웃 제외 — `ApiPaths.noRefresh`). 갱신은 `TokenRefresher` 가 **한 번에 하나**(동시 401 은 토큰 세대를 보고 묶는다). 갱신 401 · 유예 응답(`tokens: null`) → 토큰 지움 + `AUTH_002` + 세션 끝, 5xx · 연결 끊김은 세션을 두고 그 코드로 알림, 갱신 중 로그아웃이면 받은 토큰을 버리고 서버 세션도 끝낸다. 갱신의 일시 장애는 같은 토큰 세대에서 5초 동안 다시 쓴다(`kRefreshFailureReuse` — 연결이 끊긴 동안 줄 선 요청마다 갱신하지 않게). `AUTH_010` 은 세션 노티파이어에 다시 맞춰 달라고 하고(`requestRecheck` — 안전 한도 65초) renewed 면 한 번 다시 보낸다 · restricted · stale 은 알리지 않는다 · unknown 은 알린다(웹 `recheckRestriction` 과 같은 결). 같은 토큰으로 줄 선 `AUTH_010` 은 한 번만 묻는다(그사이 새 토큰이면 묻지 않고 다시 보내고, restricted 는 그 세대 동안 · unknown 은 5초 다시 쓴다). **다시 보낸 요청의 실패도 첫 요청과 같은 규칙**(401 · `AUTH_010` 마다 한 번씩 — `kAuthRetriedExtraKey` · `kRestrictionRetriedExtraKey`). **요청을 보낸 세션이 끝났으면**(세션 세대 `kSessionGenerationExtraKey` 가 다르다 — 로그아웃 · 세션 끝 · 그 뒤 다른 직원 로그인) 어떤 실패든 조용히 끝낸다 — 401 은 사용자가 로그아웃했으면 `AUTH_001` 그대로, 세션이 끝났으면 `AUTH_002`. 실패를 내보내기 전에 Bearer · 비밀 본문을 지운다(`withoutSecrets`). 처리기는 뜻밖의 예외에도 꼭 부른다(`SYS_001`). 이 인터셉터의 오류 처리 안에서 보내는 요청(갱신 · 세션 확인 · 다시 보내기)은 모두 안쪽 Dio — 같은 줄로 보내면 서로 기다린다 |
|
||||
| 오류 알림 | `lib/core/api/error_notifier.dart` | 전역 `ScaffoldMessenger` 키 하나로 스낵바(웹 `showErrorUI` 자리). 같은 코드는 3초 안에 다시 띄우지 않는다. 요청마다 끄기: `Options(extra: {kSilentExtraKey: true})` 또는 코드 목록(`ApiOptions.silentFor`) — `request_flags.dart`. **비밀이 든 요청**(비밀번호 · 코드 · 리프레시 토큰이 본문에)은 `ApiOptions.secret(...)` 으로 보낸다 — 실패 예외의 본문을 비워 던져 화면이 `AsyncValue` 로 상태에 두어도 된다 |
|
||||
| 토큰 보관 | `lib/core/storage/` — `token_store.dart` · `secure_store.dart` · `first_run_marker.dart` | 리프레시 토큰만 `flutter_secure_storage`(키 상수 `kRefreshTokenKey`), 액세스 토큰은 메모리. 새 토큰은 받자마자 저장(로그인 저장이 실패하면 그 세션을 끝내고 `SYS_001`, 갱신 저장이 실패하면 이번 실행은 메모리의 새 토큰으로 이어 가고 다음 읽기 · 저장 때 다시 쓴다). 보안 저장소 일은 부른 차례대로 하나씩(로그인 저장과 로그아웃 지우기가 엇갈리지 않게). 토큰 세대 `generation` · 세션 세대 `sessionGeneration`(지울 때만 오른다), 로그아웃은 `signOut()`(메모리를 곧바로 비우고 서버 logout 에 쓸 토큰을 돌려준다). 만료는 기기 시계로 보지 않는다 — 응답의 `Date`(서버 시각, `core/api/server_time.dart`)로 남은 시간을 재고 받은 때부터 센다. 첫 실행 표시(`shared_preferences` — 참 · 거짓 하나)가 없으면 보안 저장소를 모두 지운다(iOS Keychain 은 앱을 지워도 남는다). Android 는 백업을 껐다(`AndroidManifest` · `res/xml/data_extraction_rules.xml`) |
|
||||
| 테마 | `lib/core/theme/` | 색은 웹 `tokens.css` 를 `ColorScheme` 에 손으로 대응(`app_colors.dart` · `app_theme.dart` — 시드 생성 쓰지 않음, Material 에 없는 색은 `AppExtraColors`). 간격 · 모서리 `AppSpacing` · `AppRadius`, 누르는 곳 최소 48dp(`AppSize.touchMin`). 글자는 `app_typography.dart` 한 곳 — 화면 글 15 · 입력 칸 16 · 위 막대 제목 18 · 메일 본문 15/1.75 는 확정(`docs/design/app/README.md`), 그 밖의 역할은 임시 값 |
|
||||
| 테마 | `lib/core/theme/` | 색은 웹 `tokens.css` 를 `ColorScheme` 에 손으로 대응(`app_colors.dart` · `app_theme.dart` — 시드 생성 쓰지 않음, Material 에 없는 색은 `AppExtraColors`). 간격 · 모서리 `AppSpacing` · `AppRadius`, 누르는 곳 최소 48dp(`AppSize.touchMin`). 글자는 `app_typography.dart` 한 곳 — 화면 글 15 · 입력 칸 16 · 위 막대 제목 18 · 메일 본문 15/1.75 는 확정(`docs/design/app/README.md`), 그 밖의 역할은 임시 값. 테두리 없는 입력칸(칸 줄이 아래 선 · 초점을 그리는 쓰기 화면 · 검색 막대)은 `borderlessInputDecoration` — 테마의 입력칸 테두리를 물려받지 않는다 |
|
||||
| 글꼴 | `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,11 @@ 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 스크롤 자리를 물려받지 않는다, 업데이트 내역의 다른 글 줄).
|
||||
짜임을 둘로 가르는 화면(읽기 화면의 본문 칸 있음 · 없음)은 두 짜임에 같은 자리의 같은 위젯을 두고 값(`ConstrainedBox` 의 maxHeight)만 바꾼다 — `Flexible` · `Expanded` 로 바꿔 끼우면 그 아래 State(펼친 받는 사람 등)가 새로 만들어진다. 시험은 위치로 본다(머리 스크롤 칸의 아래 끝 = 아래 막대의 위 끝).
|
||||
`InputDecoration.collapsed` 는 테두리 없는 칸이 아니다 — `border` 만 없애고 `enabledBorder` · `focusedBorder` 등을 비워 두면 TextField 가 그 칸을 테마의 `inputDecorationTheme`(둥근 테두리 · 초점은 청록 2)로 채우고, InputDecorator 는 `border` 보다 그 칸들을 먼저 쓴다(2026-11-12 — 쓰기 화면의 칸마다 글자를 감싼 테두리). 칸 줄이 선을 따로 그리는 칸은 `borderlessInputDecoration`(`core/theme/app_theme.dart` — 여섯 테두리 모두 없음 · 여백 0)을 쓴다. `lib/` 에 `InputDecoration.collapsed(` 가 들어오면 시험(`test/core/theme/borderless_input_decoration_test.dart`)이 실패한다. 칸의 테두리는 `test/support/input_borders.dart` 의 `expectBorderlessInput`(평소 · 초점 · 꺼짐마다 InputDecorator 가 받은 꾸밈)으로 본다.
|
||||
화면을 그림으로 견줄 때(임시 시험 — 저장소에 남기지 않는다): `setUpAll` 에서 `FontLoader('Pretendard')` 에 `assets/fonts/Pretendard-*.otf` 넷을, `FontLoader('MaterialIcons')` 에 SDK 의 `bin/cache/artifacts/material_fonts/materialicons-regular.otf` 를 올리고(그러지 않으면 글자가 네모), 맨 위 repaint boundary(RenderView)의 `OffsetLayer.toImage(paintBounds)` 를 `tester.runAsync` 안에서 PNG 로 쓴다. 화면 크기는 `MailAppHarness.pump(size:)`.
|
||||
화면 아래 끝까지 닿는 스크롤(읽기 화면의 빈 본문 + 아래 막대 없는 예약 메일 등)은 `MediaQuery.paddingOf(context).bottom` 을 스크롤 끝 여백으로 더한다 — Scaffold 는 bottomNavigationBar 가 있을 때만 몸통의 아래 padding 을 0 으로 만든다. 시험은 `tester.view.padding = FakeViewPadding(bottom: 48 * 3)`(물리 픽셀 — 시험대는 3배) + `addTearDown(tester.view.resetPadding)`.
|
||||
- [ ] **린트로 강제되는 항목**: `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` 가 경고**한다.
|
||||
|
||||
@@ -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` 로 로드).
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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 가 쓸 수 있다.
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# 휴대폰 앱이 나왔고, 메일 검색과 여러 설정이 좋아졌습니다
|
||||
|
||||
- 11월 5일 새 웹메일로 옮긴 뒤 새로 생기거나 바뀐 것을 모았습니다. 앞으로 바뀌는 것도 이곳에서 알려 드리겠습니다.
|
||||
|
||||
## 휴대폰 앱
|
||||
- 안드로이드 휴대폰에서 쓰는 ARA SYSTEM 앱이 나왔습니다. 메일을 읽고 쓰고 찾을 수 있고, 답장과 전달, 첨부 파일 보내기도 됩니다.
|
||||
- 앱 받기: PC 웹메일에서 [설정] → [휴대폰 앱]을 열고 휴대폰 카메라로 QR 코드를 찍어 주세요.
|
||||
휴대폰 브라우저에서 웹메일에 로그인한 뒤 [앱 내려받기]를 누르고, 설치를 묻는 안내가 나오면 허용해 주세요.
|
||||
- 앱에는 웹메일과 같은 메일 주소와 비밀번호로 로그인합니다. 2단계 인증을 켜 두셨다면 인증 앱의 코드도 넣어 주세요.
|
||||
- 새 메일이 오면 휴대폰 알림으로 알려 드립니다. 앱의 [설정] → [새 메일 알림]에서 켜고 끌 수 있고,
|
||||
알림에는 새 메일이 몇 통인지만 나오며 보낸 사람이나 제목은 나오지 않습니다.
|
||||
- 아이폰용 앱은 아직 없습니다. 아이폰에서는 브라우저로 웹메일을 쓰거나 기본 메일 앱에 회사 메일을 연결해 주세요.
|
||||
연결 방법은 웹메일의 [설정] → [메일 프로그램 연결]에서 볼 수 있습니다.
|
||||
|
||||
## 웹메일과 휴대폰 앱
|
||||
- 메일 검색이 빨라졌고, 보낸 사람, 받는 사람, 제목, 본문, 기간, 첨부로 나눠 찾을 수도 있습니다.
|
||||
웹메일은 검색 칸의 [조건]을, 앱은 검색 화면의 [조건으로 찾기]를 눌러 주세요.
|
||||
- 찾은 말에는 노란 표시가 붙어 목록과 본문에서 금방 찾을 수 있습니다.
|
||||
- 메일을 읽다가 [이동]을 누르면 바로 다른 메일함으로 옮길 수 있습니다.
|
||||
- 업데이트 뒤 하루쯤은 새 검색을 준비하느라 검색 화면에 안내가 나올 수 있습니다. 그동안에도 찾기는 됩니다.
|
||||
|
||||
## 웹메일 · 메일 읽기와 정리
|
||||
- 메일을 열면 그 메일과 주고받은 메일이 아래에 대화로 모여 나옵니다.
|
||||
[모두 펼치기]로 한꺼번에 읽고, 펼친 메일의 [답장]으로 바로 답할 수 있습니다.
|
||||
- 목록 위의 [대화로 묶기]를 켜면 메일 목록도 대화별로 묶입니다.
|
||||
- 목록의 메일을 끌어다 왼쪽 메일함에 놓으면 그 메일함으로 옮겨집니다.
|
||||
- 메일함 이름 옆 [⋯] → [옮기기]로 메일함을 다른 메일함 아래로 옮길 수 있습니다.
|
||||
하위 메일함이 있는 메일함도 [⋯] → [지우기]로 한꺼번에 지울 수 있고, 안에 있던 메일은 휴지통으로 옮겨집니다.
|
||||
- 보낸 사람을 차단할 때 받은편지함에 있는 그 사람의 메일도 스팸메일함으로 함께 옮길 수 있습니다.
|
||||
메일을 읽다가 [⋯] → [보낸 사람 차단]을 누르고, 창에서 받은편지함의 메일도 옮기는 칸을 켜 주세요.
|
||||
- 예약한 메일을 기다리지 않고 바로 보낼 수 있습니다. 왼쪽의 [예약됨]에서 메일을 열고 [지금 보내기]를 눌러 주세요.
|
||||
|
||||
## 웹메일 · 주소록
|
||||
- 다른 메일이나 휴대폰에서 내보낸 연락처 파일을 한 번에 가져올 수 있습니다.
|
||||
[주소록]에서 [가져오기]를 누르고 파일을 고르면, 넣기 전에 몇 명이 들어가는지 먼저 확인할 수 있습니다.
|
||||
- vCard, CSV, 엑셀 파일을 넣을 수 있고, 네이버, Google, 아이폰, Outlook에서 내보낸 파일도 됩니다.
|
||||
- 이미 주소록에 있는 연락처는 건너뜁니다. 넣은 뒤 나오는 알림의 [되돌리기]를 누르면 이번에 넣은 연락처를 한꺼번에 지울 수 있습니다.
|
||||
- 내 주소록을 파일로 내보낼 수도 있습니다. [주소록]에서 [내보내기]를 누르고 vCard나 CSV를 골라 주세요. 그룹 하나만 내보낼 수도 있습니다.
|
||||
|
||||
## 웹메일 · 설정
|
||||
- 서명을 10개까지 만들어 두고 골라 쓸 수 있습니다. [설정] → [서명]에서 [새 서명]으로 만들고,
|
||||
[메일에 넣을 서명]에서 [새 메일]과 [답장과 전달]에 넣을 서명을 따로 고를 수 있습니다.
|
||||
- 메일을 쓰다가 아래의 [서명]을 누르면 다른 서명으로 바꿀 수 있습니다.
|
||||
- 부재중 자동 응답을 굵게, 색깔 같은 글자 모양으로 꾸며 쓸 수 있습니다. [설정] → [부재중 자동 응답]
|
||||
- 부재중 자동 응답에서 회사 밖에서 온 메일에는 [사내와 같은 응답], [보내지 않기], [따로 쓰기] 가운데 골라 답할 수 있습니다.
|
||||
- 휴대폰을 바꾸셨다면 2단계 인증을 새 휴대폰으로 직접 옮길 수 있습니다.
|
||||
[설정] → [2단계 인증] → [휴대폰 바꾸기]를 누르고 안내를 따라 주세요. 지금 휴대폰의 코드와 비밀번호가 필요합니다.
|
||||
|
||||
## 그 밖에
|
||||
- 화면의 안내 글을 더 알기 쉬운 말로 다듬었습니다.
|
||||
- 앞으로 새로 생기거나 바뀌는 것은 이 업데이트 내역에 올립니다. 웹메일과 앱 모두 [설정] → [업데이트 내역]에서 볼 수 있습니다.
|
||||
@@ -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`를 돌립니다. 그래도 배포되면 서버는 그 글만 건너뛰고 로그에 파일 이름과 줄 번호를 남깁니다. 서버는 그대로 뜹니다.
|
||||
- 앱은 옛 판이어도 새 글을 받으니, 앱 이야기를 쓸 때 새 판을 내지 않아도 됩니다.
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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 형식 · 검사 · 잠금 · 응답 꼴을 본다
|
||||
|
||||
@@ -20,7 +20,8 @@
|
||||
- 탭마다 이동 기록을 따로 지킨다(메일에서 읽던 화면이 설정 탭에 다녀와도 남는다). 읽기 · 쓰기는 탭 막대 없는 전체 화면이다.
|
||||
- 여러 통 고르기 중에는 탭 막대를 숨긴다(잠깐의 상태이고, 다른 탭으로 가면 고른 것을 잃는다). ✕ · 뒤로 가기로 나오면 다시 보인다.
|
||||
- 화면 낭독기 이름은 "앱 메뉴".
|
||||
- **설정 탭**: 내 정보(이름 · 주소) · 알림(새 메일 알림 켬/끔) · 앱 정보(앱 버전 · [새 버전 받기] · 오픈소스 라이선스) · 문의 연락처 · [로그아웃]. 비밀번호 · 2단계 인증 같은 계정 설정은 웹에서 한다.
|
||||
- **설정 탭**: 내 정보(이름 · 주소) · 알림(새 메일 알림 켬/끔) · 앱 정보(앱 버전 · 업데이트 내역 · [새 버전 받기] · 오픈소스 라이선스) · 문의 연락처 · [로그아웃]. 비밀번호 · 2단계 인증 같은 계정 설정은 웹에서 한다.
|
||||
업데이트 내역(2026-11-10 — 아래 「업데이트 내역」)은 앱 버전 다음 줄이고 오픈소스 라이선스 줄과 같은 꼴이다.
|
||||
[새 버전 받기](2026-11-08 — `App1Push` ④ 앱 정보 절)는 끝 아이콘이 바깥으로 여는 아이콘이고, 업데이트 화면의 단추처럼 포털의 휴대폰 앱 받기 화면을 브라우저로 연다(같은 실패 알림).
|
||||
[로그아웃]은 데이터를 지우는 일이 아니라 회색 테두리 단추이고, 누르면 확인 창(처음 초점 [취소]) — 로그아웃하면 이 휴대폰의 새 메일 알림도 오지 않는다고 알린다.
|
||||
|
||||
@@ -81,6 +82,8 @@
|
||||
- 짜임: 머리(제목 · 보낸 사람 · 받는 사람 · 시각 · 안내 줄 · 첨부)는 앱 화면으로 본문 위에 두고 남은 높이의 절반까지만 쓴다(넘으면 머리 안에서 스크롤).
|
||||
본문 WebView 는 나머지를 다 쓰고 그 안에서 스크롤한다 — 스크립트를 꺼서 내용 높이를 잴 수 없다.
|
||||
**첨부 목록은 본문 위**다(보드는 아래) — 본문이 WebView 안에서 스크롤하므로 아래에 두면 늘 화면 아래를 차지한다.
|
||||
**본문에 보이는 것이 없으면 본문 칸을 두지 않고** 머리 전체(안내 줄 · 첨부)를 화면 전체에서 한 스크롤로 보인다 — 빈 본문이라는 글은 따로 두지 않는다(2026-11-12 — 첨부만 있는 메일에서 아래 절반이 빈 칸으로 남고 첨부 목록이 위 절반 안에서 잘렸다).
|
||||
보이는 것은 공백 · 보이지 않는 글자(nbsp · 제로폭 · 방향 표시 · 한글 채움 문자 등)가 아닌 글자, 주소가 남은 그림(외부 그림은 받기 전에도), 가로줄 · 목록 표시 · 펼침 · 따옴표다 — 꾸밈(style · bgcolor · border)만으로 그린 상자 · 선은 치지 않는다. 보일 본문을 고르는 규칙은 그대로다(서식 본문이 비어 보여도 글자 본문으로 바꾸지 않는다 — 웹과 같은 본문).
|
||||
- 본문 글꼴은 기기 글꼴이다(CSP · 파일 접근 끄기로 WebView 가 Pretendard 를 쓰지 못한다). 크기 15 · 줄 간격 1.75 · 여백 16 / 20 / 24 는 보드대로, 기기 글자 크기 설정을 따르고, 앱이 어두운 테마여도 본문 바탕은 흰색이다.
|
||||
- 앱이 한 번 더 정화한다(서버 정화 뒤 — 허용 목록으로 다시 쓴다). 길이 2,000,000 · 중첩 비용 8,000,000 을 넘으면 정화하지 않고 글자 본문과 서버의 생략 안내와 같은 글을 보인다.
|
||||
서식 본문은 6,144자보다 짧고 중첩 비용이 5,000 이하일 때만 화면에서 바로, 그 밖은 다른 isolate 에서 정화한다(개발 PC 에서 바로 하는 것이 2ms 안쪽 — 휴대폰은 몇 배 느리다).
|
||||
@@ -112,6 +115,7 @@
|
||||
- 진행 막대의 화면 낭독 값은 숫자만 된다(Flutter 3.41) — 이름("… 받는 중")만 싣고 "받는 중 64%"는 화면 글로만 보인다.
|
||||
- iOS 를 붙일 때: 파일 열기 채널(지금은 "파일을 열지 못했습니다."), WKWebView 의 링크 길게 누르기 미리 보기 끄기(`setAllowsLinkPreview(false)` — 미리 보기는 판정을 거치지 않고 그 주소를 연다).
|
||||
- 실기기에서 볼 것: 넓은 표 뉴스레터(가로 스크롤 · 확대), 기기 글자 크기, 링크 · 길게 누르기(WebView 안에서 이동하지 않는지), 같은 그림을 여러 번 쓴 뉴스레터, 2MB 본문을 정화하는 시간,
|
||||
첨부만 있는 메일(본문 칸 없이 마지막 첨부까지 한 스크롤 · 그 메일에서 [이미지 보기]를 누르면 본문 칸이 생기는지),
|
||||
첨부(PDF · 오피스 · hwp 가 한컴 뷰어에서 열리는지 · 막는 형식의 알림 · eml 을 어느 앱이 여는지 — 크롬이 MHTML 로 열면 스크립트가 꺼져 있는지), 파일 · content 접근이 꺼졌는지, 폴더 밖 경로는 열지 않는지,
|
||||
TalkBack 읽는 차례(위 막대 → 머리 → 안내 → 첨부 → 본문 → 아래 막대), 뒤로 가기 몸짓, 로그아웃 뒤 받은 파일이 지워지는지.
|
||||
|
||||
@@ -174,11 +178,17 @@
|
||||
- 전달 원본 · 저장본의 첨부와 대용량 파일은 싣고 빼기만 한다. 문제가 있으면 줄 테두리가 빨갛고 줄 아래에 까닭을 적는다(함께 보낼 수 없음 · 찾지 못함 `MAIL_004` · 올리다 멈춤 · 지워짐 · 위험한 파일).
|
||||
- 복사본은 뺄 때 · 화면이 끝날 때 · 세션이 끝날 때 · 앱을 띄울 때 · 메일을 보는 직원이 바뀔 때 지운다.
|
||||
- 글자 크기는 테마 크기로 맞췄다(15.5 → 16 · 14와 14.5 → 15 · 13 → 13 · 12.5 → 12).
|
||||
- 입력칸(받는 사람 · 제목 · 본문)은 테두리 · 바탕 없이 글만 그리고, 선은 칸 줄의 아래 선(초점이면 청록 2)뿐이다 — 보드대로. 검색 막대의 칸도 테두리 없이 글만 그린다.
|
||||
앱 테마의 입력칸 테두리를 물려받지 않게 `borderlessInputDecoration`(`core/theme/app_theme.dart`)을 쓴다(2026-11-12 — `InputDecoration.collapsed` 가 테마의 둥근 테두리를 물려받아 칸마다 글자를 바짝 감싼 테두리가 그려지고, 초점이면 칸 줄의 아래 선과 청록 테두리가 겹쳤다).
|
||||
- 칸 줄(2026-11-12 — 보드에 맞춤): 최소 높이 56 · 여백 위 4 · 오른쪽 4 · 아래 4 · 왼쪽 16, 칸 이름 폭 72 · 글자 15(보드 14) · 칸 이름과 칸 사이 6. 아래 선은 자리를 차지하지 않아 초점으로 굵어져도 칸이 움직이지 않는다.
|
||||
**받는 사람 칩이 여러 줄이면 칸 이름과 [참조 펼치기]는 첫 칩 줄에 맞춘다**(보드는 한 줄만 그렸다 — 줄 전체의 가운데로 내려가지 않게). 칩 ✕ 는 #4A5363, 제목 · 본문의 안내 글은 보드의 placeholder 색 #8A94A3(검색 칸과 같다).
|
||||
본문 칸 최소 높이는 원본 메일이 아래에 있으면(답장 · 전달) 150, 없으면(새 메일 — 서명이 있어도) 240 이다(보드의 「새 메일 · 받는 사람 추천」 240 · 「답장 · 참조 펼침 · 첨부 · 원본 접힘」 150).
|
||||
- **알려진 한계**: 앱이 안 보이게 될 때 칸에 쓰다 만 주소(칩이 되지 않은 글)는 저장에 들지 않고, 나가기 확인 창이 떠 있는 동안은 저장하지 않는다. 앱이 앞에 있는 채로 정리되면 마지막 몇 초를 잃는다.
|
||||
고르는 동안 앱이 정리되면 고른 파일은 버린다(쓰던 내용은 고르는 창을 열 때 저장된다). 고르는 창에 10분 넘게 머물면 그 고르기는 시한으로 끝난다(알림이 창 뒤에서 뜰 수 있다 — 다시 고른다).
|
||||
**알림 · 업데이트 화면으로 내려갈 때**(2026-11-07): 새 메일 알림을 누르거나 업데이트 화면으로 옮겨(다른 화면으로 `go`) 쓰기 화면이 내려가면, 쓰다 만 주소까지 임시보관함에 마저 저장하고
|
||||
"임시보관함에 저장했습니다."(저장하지 못하면 그 코드의 사전 문구)로 알린다. 로그아웃 · 세션 끝 · 제한 단계로 옮겨 내려갈 때는 알리지 않는다(그 세션의 쓰기가 끝났다).
|
||||
- **실기기에서 볼 것**: 삼성 · Gboard 자판의 칩 만들기(쉼표 · [다음] · 한글 조합 중 확정 · 여러 줄 붙여 넣기), 추천 목록이 자판에 가리는지, 예측 뒤로 가기, 홈 · 다른 앱으로 갔을 때 저장, 긴 원문 펼침 스크롤,
|
||||
기기 글자 크기를 키웠을 때 칸 이름(숨은참조 — 폭 72 에서 낱말 가운데 꺾임),
|
||||
서명 그림 폭, TalkBack 차례(칩 · 추천 · 원본 펼침 · 띠), 제스처 내비게이션에서 읽기 화면 알림 자리, 웹 ↔ 앱으로 저장본을 서로 다시 열기, 앱 로그인 서버(feat/app-auth)와 함께 실제 보내기 · 저장 · 서명 그림.
|
||||
첨부(2차): 고르는 창의 출처마다(내 파일 · Google 드라이브 · 사진 · 다운로드) 이름 · 크기 · 확장자를 주는지, hwp · hwpx 가 창에 보이고 올라가는지 · HEIC 는 `FILE_001` 인지,
|
||||
20MB 넘는 파일 · 크기를 알려 주지 않는 클라우드 파일이 한도에서 멈추는지, 합계에 걸리는 큰 파일을 내려받지 않고 바로 알리는지, 받는 동안의 진행 막대, 받다 망이 끊길 때(비행기 모드) 가져오기가 끝나는지, 여러 개 · 남은 자리보다 많이 고르기,
|
||||
@@ -303,5 +313,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. 검색은 결과에서 여러 통 고르기를 미뤘다(읽기 화면의 같은 대화 · 목록 대화로 묶기도 — 보드 먼저).
|
||||
|
||||
@@ -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 는 선택 풀기 (두 보기 공통).
|
||||
여러 통을 고르는 동안 본문 칸은 열어 둔 메일을 그대로 보인다.
|
||||
@@ -1316,6 +1339,8 @@ PC 에서는 설정 › 계정 › 휴대폰 앱(A)이 QR 코드로 휴대폰에
|
||||
- 남은 자리보다 많으면 노란 상자 "연락처는 3,000개까지 둘 수 있어 앞에서부터 188명만 넣습니다. 나머지 142명은 넣지 않습니다." — 단추의 수도 그만큼.
|
||||
- [연락처 330명 넣기](처음 초점) [취소]. 넣을 연락처가 없으면 [연락처 넣기]를 끄고(`aria-disabled`) 단추 왼쪽에 "넣을 연락처가 없습니다."
|
||||
- Esc · [취소]는 묻지 않고 닫는다(아직 아무것도 넣지 않았다). 창이 화면보다 길면 창 안이 스크롤하고 단추 줄은 고정하지 않는다(필터 창과 같다).
|
||||
창 안의 칸은 줄어들지 않는다 — 세 묶음 상자(둥근 모서리 때문에 `overflow: hidden`)는 `flex-shrink: 0`. 창(`<dialog>`)이 flex 열이라 overflow 를 건 칸은 내용보다 작게 눌릴 수 있다
|
||||
(2026-11-10 실사용 시험 — 1366×768 · 1440×900 에서 상자가 눌려 [이미 있는 연락처는 건너뜁니다] · "그 밖에 N명"이 안 보이고 창도 스크롤하지 않았다). 내보내기 창도 같다.
|
||||
- **넣는 동안 · 끝난 뒤**(알림 — 필터 적용 · 차단 정리와 같은 결): [연락처 N명 넣기]를 누르면 창을 닫고 진행 알림 "연락처를 넣는 중 · 120 / 330명"(넣은 수 / 넣을 수 — 아래 「요청 묶음」 하나가 끝날 때마다 고친다).
|
||||
- 끝: 같은 자리에서 "연락처 330명을 넣었습니다. 이미 있던 8명은 건너뛰었습니다." + [되돌리기] + ✕ — **✕로 닫을 때까지 남는다**(저절로 닫히지 않는다).
|
||||
건너뛴 수 · 한도에 걸린 수는 한 문장씩 덧붙인다 — "연락처는 3,000개까지 둘 수 있어 나머지 142명은 넣지 못했습니다." · "그룹은 100개, 그룹 하나에는 500명까지 둘 수 있어 24명은 그룹에 넣지 못했습니다."
|
||||
@@ -1434,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 — 캔버스에는 아직 없다)
|
||||
|
||||
- 찾지 못한 첨부(보관 시간이 지난 업로드·지워진 원본)는 실패로 표시하고, 전달 원본에는 "빼기"만 둔다(다시 올릴 수 없다).
|
||||
@@ -1973,7 +2062,8 @@ PC 에서는 설정 › 계정 › 휴대폰 앱(A)이 QR 코드로 휴대폰에
|
||||
쓰기 화면이 처음 넣는 서명은 표시(`data-mail-signature` — 서버 정화가 지운다)로 안다.
|
||||
- **서명 앞의 빈 줄은 늘 둔다**(2026-11-10 리뷰 M3 — 빈 줄에 글을 쓰거나 지운 채 저장하면 다시 열 때 서명 자리를 잃었다): 그 빈 줄에 글을 쓰면 쓴 글 아래 · 서명 위에 새 빈 줄이 생기고,
|
||||
Backspace · Delete 로 빈 줄을 지우면 다시 생긴다(커서는 쓰던 자리 · 윗줄 끝, 실행 취소는 그 입력과 함께 — 쓴 글은 지우지 않는다).
|
||||
한글을 조합하는 중에는 미뤘다가 다음 입력에서 넣고, 보내고 저장하는 모양도 빠진 빈 줄을 한 번 더 넣는다.
|
||||
한글을 조합하는 중에는 미뤘다가 조합이 끝나면(화살표 · 다른 칸 누르기 등 — 다른 입력이 없어도) 넣고(2026-11-10 실사용 시험 — 띄어쓰기를 쳐야 돌아왔다),
|
||||
보내고 저장하는 모양도 빠진 빈 줄을 한 번 더 넣는다. 조합 중에 저장한 뒤 편집기가 넣은 빈 줄은 바꿈으로 세지 않는다(저장한 본문과 같다).
|
||||
알려진 한계: 16단계 앞에 저장한 본문(서명이 문단으로만 들어 있다)은 서명 자리를 찾지 못한다 — 그 서명은 글로 남고, [서명 ▾]는 ✓ 없이 열리며 고르면 처음 넣는 자리에 새로 넣는다.
|
||||
- **서명 자리는 하나**: 서명 안에서 복사해 붙여 넣으면 편집기가 서명 자리째 붙이는데, 그때 처음부터 있던 것만 두고 나머지는 글로 푼다(쓰기 화면의 편집기에서만 — 붙여 넣기 · 끌어 놓기 모두).
|
||||
- **어느 서명이 들었나**: 서명 자리의 글(공백 · nbsp 를 하나로 줄여)과 그림 주소를 저장한 서명(편집기로 읽은 모양)과 견준다 — 만든 차례로 먼저 맞는 서명에 ✓. 본문이 바뀔 때마다 다시 잰다(서명 안을 고치면 ✓ 가 빠진다).
|
||||
@@ -1999,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
@@ -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": []
|
||||
}
|
||||
|
||||
@@ -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 — 뱃지를 쓰지 않는다), 웹 · 앱 거르기, 글 안의 링크 · 그림 · 굵게, 검색, 번호 · 작성자 · 조회 수.
|
||||
|
||||
+11
-11
@@ -3,14 +3,14 @@
|
||||
그룹웨어 전체의 작업 차례다. 단계 번호는 모듈마다 따로 센다 — 메일의 "N단계"는 API 계약([`docs/mail/api-contract.md`](./mail/api-contract.md))과
|
||||
화면 디자인([`docs/design/webmail/README.md`](./design/webmail/README.md))의 "N단계"와 같다. 단계마다 **조사 → 캔버스(화면 보드) → 확정 → 구현 → 배포** 차례로 한다.
|
||||
|
||||
마지막 정리: 2026-11-10
|
||||
마지막 정리: 2026-11-12
|
||||
|
||||
## 지금
|
||||
|
||||
- **운영**: 메일 1~13단계. 2026-11-05 옛 메일 서버(mailcow)에서 전 직원이 옮겨 왔다(메일만 옮겼다 — 주소록 · 달력 · 필터 · 부재중 · 서명은 직원이 새로).
|
||||
- **배포를 기다림**: 메일 14 · 15단계 · 앱 서버(앱 로그인 · 새 메일 알림) · 휴대폰 앱 받기 — main 에 합쳤고, 전환 뒤 안정화 1~2주를 지나 11월 셋째 주에 운영에 올린다.
|
||||
- **개발 중**: 메일 16단계(개인 설정).
|
||||
- **다음**: 16단계가 끝날 즈음 아래 "추후 후보" 가운데 직원 요청이 실제로 있는 것을 고른다. 그다음은 공통 기반(조직 · 권한).
|
||||
- **운영**: 메일 1~16단계 · 휴대폰 앱 첫 판(안드로이드 1.0.1) · 업데이트 내역. 2026-11-05 옛 메일 서버(mailcow)에서 전 직원이 옮겨 왔고(메일만 옮겼다 — 주소록 · 달력 · 필터 · 부재중 · 서명은 직원이 새로),
|
||||
2026-11-12 0시에 14 · 15 · 16단계 · 앱 서버(앱 로그인 · 새 메일 알림 · 휴대폰 앱 받기) · 업데이트 내역을 운영에 올리고 휴대폰 앱(1.0.0)을 나눠 주기 시작했다.
|
||||
같은 날 배포 뒤 받은 의견(본문 없이 첨부만 있는 메일의 앱 읽기 화면 · 앱 쓰기 화면 입력칸)을 고친 앱 1.0.1 을 올렸다. 1.0.0 도 그대로 쓸 수 있다.
|
||||
- **다음**: 아래 "추후 후보" 가운데 직원 요청이 실제로 있는 것을 고른다. 그다음은 공통 기반(조직 · 권한).
|
||||
|
||||
## 1부 — 메일
|
||||
|
||||
@@ -29,11 +29,11 @@
|
||||
| 11 | 대용량 첨부 — 링크로 보내기 · 조각 올리기 · 30일 보관 · 로그인 없는 내려받기 화면 · 악성 검사 | 운영 |
|
||||
| 12 | 계정 보안 — 2단계 인증 · 앱 비밀번호 · 로그인 기록 · 로그인된 기기 | 운영 |
|
||||
| 13 | 전환 준비 — 새 비밀번호 · 2단계 필수 · 메일 프로그램 연결 안내 · 운영 도구 · 백업 · 감시 · 휴지통 · 스팸 30일 | 운영 |
|
||||
| 14 | 자체 검색 색인 · 조건 검색 · 검색어 강조 · 같은 대화 · 대화로 묶어 보기 | main 에 합침 — 배포 기다림 |
|
||||
| 15 | 메일 · 메일함 다루기 — 읽기 화면 [이동] · 끌어다 놓기 · 메일함 옮기기 · 하위까지 지우기 · 차단할 때 받은편지함 정리 · 예약 메일 [지금 보내기] · 같은 대화 [모두 펼치기] | main 에 합침 — 배포 기다림 |
|
||||
| 16 | 개인 설정 — **주소록 가져오기 · 내보내기**(vCard · CSV · 엑셀) · 여러 서명(사내 · 대외 · 영문) · 부재중 응답의 서식 본문과 사내/외부 구분 · 2단계 인증 휴대폰 직접 바꾸기 | 개발 중 |
|
||||
| 14 | 자체 검색 색인 · 조건 검색 · 검색어 강조 · 같은 대화 · 대화로 묶어 보기 | 운영(2026-11-12) |
|
||||
| 15 | 메일 · 메일함 다루기 — 읽기 화면 [이동] · 끌어다 놓기 · 메일함 옮기기 · 하위까지 지우기 · 차단할 때 받은편지함 정리 · 예약 메일 [지금 보내기] · 같은 대화 [모두 펼치기] | 운영(2026-11-12) |
|
||||
| 16 | 개인 설정 — **주소록 가져오기 · 내보내기**(vCard · CSV · 엑셀) · 여러 서명(사내 · 대외 · 영문) · 부재중 응답의 서식 본문과 사내/외부 구분 · 2단계 인증 휴대폰 직접 바꾸기 | 운영(2026-11-12) |
|
||||
|
||||
- 15단계부터는 직원이 쓰며 바라는 것을 먼저 본다 — 16단계가 끝날 즈음 아래 "추후 후보" 가운데 요청이 실제로 있는 것을 골라 다음 단계로 넣는다.
|
||||
- 15단계부터는 직원이 쓰며 바라는 것을 먼저 본다 — 아래 "추후 후보" 가운데 요청이 실제로 있는 것을 골라 다음 단계로 넣는다.
|
||||
- 2026-11-09 계획에서 뺀 것: 수신 확인 · 사내 발송 취소(사내 받는 사람에게만 되고, 흔한 실수는 보내기 취소가 막는다 — 아래 후보로).
|
||||
|
||||
## 공통 기반 — 조직 · 권한
|
||||
@@ -48,8 +48,8 @@
|
||||
|
||||
| 판 | 내용 | 상태 |
|
||||
| --- | --- | --- |
|
||||
| 앱 1단계 | 로그인(코드 · 새 비밀번호 · 2단계 켜기) · 메일(목록 · 읽기 · 쓰기 · 첨부 · 검색 · 이동 · 삭제) · 새 메일 알림(안드로이드) | main 에 합침 — 서버 배포 뒤 직원 배포 |
|
||||
| 사내 배포 | 스토어 없이 우리 키로 서명한 APK 를 포털의 휴대폰 앱 받기 화면에서 | main 에 합침 — 서명 키 준비됨 |
|
||||
| 앱 1단계 | 로그인(코드 · 새 비밀번호 · 2단계 켜기) · 메일(목록 · 읽기 · 쓰기 · 첨부 · 검색 · 이동 · 삭제) · 새 메일 알림(안드로이드) | 운영 — 1.0.1(2026-11-12) |
|
||||
| 사내 배포 | 스토어 없이 우리 키로 서명한 APK 를 포털의 휴대폰 앱 받기 화면에서 | 운영 — 1.0.1 을 올림(2026-11-12) |
|
||||
| 다음 판 후보 | 주소록 · 대용량 첨부 · 필터 · 서식 편집 · 예약 메일 [지금 보내기] | 첫 판 반응을 보고 고른다 |
|
||||
|
||||
## 추후 후보 (필요해지면)
|
||||
|
||||
+3
-2
@@ -137,8 +137,9 @@ flutter build apk --release
|
||||
Copy-Item build\app\outputs\flutter-apk\app-release.apk "$env:USERPROFILE\Desktop\ARA-SYSTEM-1.0.1.apk" # 이름은 ARA-SYSTEM-<버전>.apk
|
||||
```
|
||||
|
||||
- 나오는 파일은 `build/app/outputs/flutter-apk/app-release.apk` 하나다(모든 ABI — arm64-v8a · armeabi-v7a · x86_64). 저장소 밖으로 옮기며 이름을 `ARA-SYSTEM-<버전>.apk` 로 바꾼다(받기 화면이 주는 파일 이름과 같은 꼴).
|
||||
- 올리기 전에 본다:
|
||||
- 나오는 파일은 `build/app/outputs/flutter-apk/app-release.apk` 하나다(모든 ABI — arm64-v8a · armeabi-v7a · x86_64 — 그래서 1.0.0 이 64.4MB 다). 저장소 밖으로 옮기며 이름을 `ARA-SYSTEM-<버전>.apk` 로 바꾼다(받기 화면이 주는 파일 이름과 같은 꼴).
|
||||
- 올리기 전에 본다 — `apksigner` 는 Java 로 돈다. PATH 에 `java` 가 없으면 결과 없이 끝나니(2026-11-11 개발 PC) 먼저 Android Studio 의 Java 를 잡는다:
|
||||
`$env:JAVA_HOME = "C:\Program Files\Android\Android Studio\jbr"; $env:Path = "$env:JAVA_HOME\bin;$env:Path"`
|
||||
- 우리 키로 서명했는지 — `& "$env:LOCALAPPDATA\Android\Sdk\build-tools\36.1.0\apksigner.bat" verify --print-certs <파일>` 의 `certificate DN` 이 keytool 에 넣은 이름이다(`CN=Android Debug` 면 debug 키다).
|
||||
- 파일 안의 판이 파일 이름과 같은지 — `& "$env:LOCALAPPDATA\Android\Sdk\build-tools\36.1.0\aapt2.exe" dump badging <파일> | Select-String "^package:"` 의
|
||||
`versionName` 이 파일 이름의 버전, `versionCode` 가 `pubspec.yaml` 의 `+` 뒤 숫자다(예 `versionCode='2' versionName='1.0.1'`).
|
||||
|
||||
@@ -171,10 +171,10 @@ abstract final class AppSize {
|
||||
/// 쓰기 화면의 첨부 줄 파일 아이콘 칸
|
||||
static const double composeFileIconBox = 36;
|
||||
|
||||
/// 본문 칸의 최소 높이 — 새 메일(원본 메일이 아래에 없다)
|
||||
/// 본문 칸의 최소 높이 — 원본 메일이 아래에 없는 쓰기(새 메일 등 — 서명은 있어도)
|
||||
static const double composeBodyMinHeight = 240;
|
||||
|
||||
/// 본문 칸의 최소 높이 — 답장 · 전달(아래에 서명 · 원본 메일)
|
||||
/// 본문 칸의 최소 높이 — 답장 · 전달(아래에 원본 메일)
|
||||
static const double composeBodyMinHeightWithQuote = 150;
|
||||
|
||||
/// 원본 메일 접힌 줄의 최소 높이
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -231,6 +231,34 @@ abstract final class AppTheme {
|
||||
);
|
||||
}
|
||||
|
||||
/// 테두리 · 바탕 · 안쪽 여백이 없는 입력칸 꾸밈 — 칸을 담은 줄이 아래 선 · 초점 표시를 따로 그리는 칸
|
||||
/// (쓰기 화면의 받는 사람 · 제목 · 본문 — `ComposeFieldRow`, 메일 검색 막대).
|
||||
///
|
||||
/// `InputDecoration.collapsed` 는 쓰지 않는다 — collapsed 는 `border` 만 없애고 `enabledBorder` · `focusedBorder` 등을 비워 두는데,
|
||||
/// TextField 가 그 빈 칸을 테마의 입력칸 테두리(위 `inputDecorationTheme` — 둥근 테두리 · 초점은 청록 2)로 채우고, InputDecorator 는
|
||||
/// `border` 보다 이 칸들을 먼저 쓴다. 그래서 여섯 테두리를 모두 [InputBorder.none] 으로 적는다
|
||||
/// (2026-11-12 — 쓰기 화면의 칸마다 글자를 바짝 감싼 둥근 테두리가 그려지고, 초점이 오면 칸 줄의 아래 선과 청록 테두리가 겹쳤다).
|
||||
/// 여백 · 높이는 collapsed 와 같다(안쪽 여백 0 · `isCollapsed`). [hint] 와 [hintText] 는 하나만 준다
|
||||
InputDecoration borderlessInputDecoration({
|
||||
String? hintText,
|
||||
TextStyle? hintStyle,
|
||||
Widget? hint,
|
||||
}) => InputDecoration(
|
||||
hintText: hintText,
|
||||
hintStyle: hintStyle,
|
||||
hint: hint,
|
||||
isCollapsed: true,
|
||||
isDense: false,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
filled: false,
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
disabledBorder: InputBorder.none,
|
||||
errorBorder: InputBorder.none,
|
||||
focusedErrorBorder: InputBorder.none,
|
||||
);
|
||||
|
||||
/// Material 색 역할에 없는 웹 토큰 — `Theme.of(context).extension<AppExtraColors>()!` 로 쓴다
|
||||
@immutable
|
||||
class AppExtraColors extends ThemeExtension<AppExtraColors> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/// 글에 보이는 글자가 있는가 — 순수 함수 (모듈 공통 — 메일 본문이 비었는지 등).
|
||||
///
|
||||
/// 아래 글자만 있는 글은 보이는 것이 없다(빈 글이다):
|
||||
/// - 공백: Dart `trim()` 이 지우는 공백 전부 — 빈칸 · 탭 · 줄바꿈 · nbsp · 전각 빈칸 · 줄 · 문단 구분 · BOM 등
|
||||
/// - 제어 문자: C0 · DEL · C1
|
||||
/// - 보이지 않는 글자: 유니코드의 Default_Ignorable_Code_Point — 제로폭 빈칸 · 이음(ZWNJ · ZWJ · WORD JOINER) · 방향 표시(LRM · RLM · 끼워 넣기 · 격리) ·
|
||||
/// soft hyphen · 이체 선택자 · 한글 채움 문자(U+115F · U+1160 · U+3164 · U+FFA0) · 태그 글자 등
|
||||
/// - 점자 빈칸(U+2800) — 글자이지만 빈칸으로 그려진다
|
||||
library;
|
||||
|
||||
/// [text] 에 보이는 글자가 하나라도 있는가 — 앞에서부터 보고 처음 보이는 글자에서 멈춘다(길이에 비례)
|
||||
bool hasVisibleText(String text) {
|
||||
for (final int rune in text.runes) {
|
||||
if (!_isInvisible(rune)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// 보이지 않는 글자인가 — 위 머리말의 목록
|
||||
bool _isInvisible(int rune) {
|
||||
// C0 제어 문자 · 빈칸
|
||||
if (rune <= 0x20) return true;
|
||||
// 그 밖의 ASCII 는 모두 보인다
|
||||
if (rune < 0x7F) return false;
|
||||
// DEL · C1 제어 문자(NEL 포함) · nbsp
|
||||
if (rune <= 0xA0) return true;
|
||||
for (final (int first, int last) in _invisibleRanges) {
|
||||
if (rune < first) return false;
|
||||
if (rune <= last) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// 보이지 않는 글자의 범위 — (처음, 끝) 끝 포함 · 작은 차례대로(U+00A0 까지는 위에서 따로 본다)
|
||||
const List<(int, int)> _invisibleRanges = <(int, int)>[
|
||||
(0x00AD, 0x00AD), // soft hyphen
|
||||
(0x034F, 0x034F), // 글자 묶음 이음(CGJ)
|
||||
(0x061C, 0x061C), // 아랍 글자 방향 표시
|
||||
(0x115F, 0x1160), // 한글 초성 · 중성 채움 문자
|
||||
(0x1680, 0x1680), // 오검 빈칸
|
||||
(0x17B4, 0x17B5), // 크메르의 소리 없는 모음
|
||||
(0x180B, 0x180F), // 몽골 이체 선택자 · 모음 구분
|
||||
(0x2000, 0x200F), // 너비가 다른 빈칸 · 제로폭 빈칸 · 이음 · 방향 표시
|
||||
(0x2028, 0x202F), // 줄 · 문단 구분 · 방향 끼워 넣기 · 좁은 nbsp
|
||||
(0x205F, 0x206F), // 수학 빈칸 · WORD JOINER · 보이지 않는 연산자 · 방향 격리
|
||||
(0x2800, 0x2800), // 점자 빈칸
|
||||
(0x3000, 0x3000), // 전각 빈칸
|
||||
(0x3164, 0x3164), // 한글 채움 문자
|
||||
(0xFE00, 0xFE0F), // 이체 선택자
|
||||
(0xFEFF, 0xFEFF), // BOM(제로폭 nbsp)
|
||||
(0xFFA0, 0xFFA0), // 반각 한글 채움 문자
|
||||
(0xFFF0, 0xFFF8), // 보이지 않게 정해 둔 빈 자리
|
||||
(0x1BCA0, 0x1BCA3), // 속기 서식 제어
|
||||
(0x1D173, 0x1D17A), // 악보 서식 제어
|
||||
(0xE0000, 0xE0FFF), // 태그 글자 · 이체 선택자 보충
|
||||
];
|
||||
@@ -11,8 +11,11 @@
|
||||
///
|
||||
/// 외부 이미지를 보일 때(서버가 중계 주소 `/api/mail-images/<표>` 로 바꿔 넣은 본문 — imagesShown)도 WebView 는 바깥에 나가지 않는다 —
|
||||
/// 앱이 Dio(Bearer)로 그림을 받아 `data:` 로 바꿔 넣는다([resolveProxiedImages]). 토큰이 WebView 에 가지 않는다.
|
||||
///
|
||||
/// 정화하며 본문에 보이는 것이 있는지도 함께 본다([SanitizedMailBody.hasVisibleContent]) — 없으면 읽기 화면이 본문 칸을 그리지 않는다.
|
||||
library;
|
||||
|
||||
import 'package:arasystem_app/core/utils/visible_text.dart';
|
||||
import 'package:arasystem_app/features/mail/domain/mail_images.dart';
|
||||
import 'package:arasystem_app/features/mail/domain/search_highlight.dart';
|
||||
import 'package:html/dom.dart' as dom;
|
||||
@@ -67,6 +70,11 @@ const Set<String> _droppedWithContent = <String>{
|
||||
/// 내용이 없는 태그 — 닫는 태그를 쓰지 않는다
|
||||
const Set<String> _voidTags = <String>{'br', 'col', 'hr', 'img', 'wbr'};
|
||||
|
||||
/// 남기는 태그 가운데 글 없이도 그려지는 것 — 가로줄 · 목록 표시(• · 1.) · 펼침 표시(▶ 와 기본 이름) · 따옴표.
|
||||
/// 그림(`img`)은 주소가 남았을 때만 보인다(주소를 지운 그림은 문서가 숨긴다 — [buildMailDocument]) — 따로 본다.
|
||||
/// 모양 속성(style · bgcolor · border)만으로 그린 상자 · 선 · 바탕과 빈 인용의 왼쪽 막대는 보이는 것으로 치지 않는다 — 글도 그림도 없는 꾸밈뿐이다
|
||||
const Set<String> _drawnWithoutText = <String>{'hr', 'li', 'details', 'q'};
|
||||
|
||||
/// 어느 태그에나 남기는 속성 — 모양(style) · 표 · 글꼴의 옛 속성. 이벤트 처리기 · data-* · id · target · 외부 자원 속성은 없다
|
||||
const Set<String> _globalAttributes = <String>{
|
||||
'style', 'class', 'title', 'dir', 'lang', 'align', 'valign', 'width', //
|
||||
@@ -109,6 +117,7 @@ class SanitizedMailBody {
|
||||
const SanitizedMailBody({
|
||||
required this.fragment,
|
||||
required this.proxiedTokens,
|
||||
required this.hasVisibleContent,
|
||||
this.htmlOmitted = false,
|
||||
});
|
||||
|
||||
@@ -118,6 +127,12 @@ class SanitizedMailBody {
|
||||
/// 받아 넣을 중계 그림의 표 — 문서 차례 · 겹치지 않게 · [kMaxProxiedImages] 까지(외부 이미지를 보일 때만)
|
||||
final List<String> proxiedTokens;
|
||||
|
||||
/// 본문에 보이는 것이 있다 — 공백 · 보이지 않는 글자가 아닌 글자([hasVisibleText]), 주소가 남은 그림, 글 없이도 그려지는 태그
|
||||
/// (가로줄 · 목록 표시 · 펼침 표시 · 따옴표). 거짓이면 읽기 화면이 본문 칸(WebView)을 그리지 않는다.
|
||||
///
|
||||
/// 중계 그림은 받기 전에도 보이는 것으로 친다 — 하나도 받지 못하면 빈 본문 칸이 남는다(본문을 다시 보지 않는다)
|
||||
final bool hasVisibleContent;
|
||||
|
||||
/// 서식 본문이 앱의 정화 한도(길이 · 중첩)를 넘어 글자 본문을 보인다 — 화면은 서버의 생략과 같은 안내를 보인다
|
||||
final bool htmlOmitted;
|
||||
}
|
||||
@@ -130,7 +145,10 @@ String? displayedHtmlBody(String? htmlBody) =>
|
||||
/// [imagesShown] 이면 서버의 중계 주소 그림을 남긴다(그 밖의 바깥 주소 · 상대 경로 그림은 어느 때나 지운다).
|
||||
/// 서식 본문이 길이 · 중첩 한도([kMaxSanitizedHtmlLength] · [kMaxHtmlNestingCost])를 넘으면 정화하지 않고 글자 본문을 보인다.
|
||||
/// [textHighlights] 는 글 본문의 검색에서 맞은 곳(검색 결과에서 연 메일 — 14단계) — 글 본문을 보일 때만 `<mark>` 로 넣는다
|
||||
/// (서식 본문은 서버가 정화한 뒤 맞은 글 조각에 `<mark>` 를 넣어 보냈고, 앱의 정화가 그대로 남긴다)
|
||||
/// (서식 본문은 서버가 정화한 뒤 맞은 글 조각에 `<mark>` 를 넣어 보냈고, 앱의 정화가 그대로 남긴다).
|
||||
///
|
||||
/// 고른 본문에 보이는 것이 있는지([SanitizedMailBody.hasVisibleContent])도 함께 낸다 — 고르는 규칙은 그대로다(서식 본문이 비어 보여도
|
||||
/// 글자 본문으로 바꾸지 않는다 — 웹 `displayedHtmlBody` 와 같은 본문을 보인다)
|
||||
SanitizedMailBody mailBodyFragment({
|
||||
required String? htmlBody,
|
||||
required String? textBody,
|
||||
@@ -162,6 +180,7 @@ SanitizedMailBody _textFragment(
|
||||
fragment:
|
||||
'<div class="mail-text">${highlightedTextHtml(textBody ?? '', highlights)}</div>',
|
||||
proxiedTokens: const <String>[],
|
||||
hasVisibleContent: hasVisibleText(textBody ?? ''),
|
||||
htmlOmitted: htmlOmitted,
|
||||
);
|
||||
|
||||
@@ -316,15 +335,22 @@ const Set<String> _htmlVoidTags = <String>{
|
||||
/// 허용 목록 밖의 태그는 태그만 지우고 글은 남긴다(`<o:p>` · `<form>` · `<button>` 등), [_droppedWithContent] 는 내용째 버린다.
|
||||
/// HTML 이 아닌 이름공간(SVG · MathML)은 내용째 버린다. 주석은 지운다. 깊은 중첩에도 스택이 넘치지 않게 되풀이로 돈다.
|
||||
/// 문서 전체로 읽어 `<body>` 아래만 쓴다 — 조각으로 읽으면(`parseFragment`) 맨 위 요소가 많을 때 파서가 옮기며 제곱으로 느려진다
|
||||
/// (2026-11-06 측정: 맨 위 span 4만 개 10초 → 문서로 0.3초). 머리에 들어가는 태그(meta · title · style 등)는 어차피 버린다
|
||||
/// (2026-11-06 측정: 맨 위 span 4만 개 10초 → 문서로 0.3초). 머리에 들어가는 태그(meta · title · style 등)는 어차피 버린다.
|
||||
/// 쓰는 동안 보이는 것이 있는지([SanitizedMailBody.hasVisibleContent])도 본다 — 남긴 글 · 주소가 남은 그림 · [_drawnWithoutText] 만
|
||||
/// (내용째 버린 것 · 주석은 보이지 않는다). 처음 찾으면 더 보지 않는다
|
||||
SanitizedMailBody sanitizeMailHtml(String html, {required bool imagesShown}) {
|
||||
final dom.Element? body = html_parser.parse(html).body;
|
||||
final StringBuffer out = StringBuffer();
|
||||
final List<String> tokens = <String>[];
|
||||
final Set<String> seen = <String>{};
|
||||
if (body == null) {
|
||||
return const SanitizedMailBody(fragment: '', proxiedTokens: <String>[]);
|
||||
return const SanitizedMailBody(
|
||||
fragment: '',
|
||||
proxiedTokens: <String>[],
|
||||
hasVisibleContent: false,
|
||||
);
|
||||
}
|
||||
bool visible = false;
|
||||
|
||||
// 되풀이 깊이 우선 — 닫는 태그는 글로 쌓는다
|
||||
final List<Object> stack = <Object>[..._reversed(body.nodes)];
|
||||
@@ -337,6 +363,7 @@ SanitizedMailBody sanitizeMailHtml(String html, {required bool imagesShown}) {
|
||||
final dom.Node node = item as dom.Node;
|
||||
if (node is dom.Text) {
|
||||
out.write(escapeHtmlText(node.data));
|
||||
if (!visible) visible = hasVisibleText(node.data);
|
||||
continue;
|
||||
}
|
||||
if (node is! dom.Element) continue;
|
||||
@@ -348,24 +375,29 @@ SanitizedMailBody sanitizeMailHtml(String html, {required bool imagesShown}) {
|
||||
stack.addAll(_reversed(node.nodes));
|
||||
continue;
|
||||
}
|
||||
final String attributes = _attributesOf(
|
||||
node,
|
||||
name,
|
||||
imagesShown: imagesShown,
|
||||
onProxied: (String token) {
|
||||
if (seen.contains(token)) return true;
|
||||
if (tokens.length >= kMaxProxiedImages) return false;
|
||||
seen.add(token);
|
||||
tokens.add(token);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
out
|
||||
..write('<')
|
||||
..write(name)
|
||||
..write(
|
||||
_attributesOf(
|
||||
node,
|
||||
name,
|
||||
imagesShown: imagesShown,
|
||||
onProxied: (String token) {
|
||||
if (seen.contains(token)) return true;
|
||||
if (tokens.length >= kMaxProxiedImages) return false;
|
||||
seen.add(token);
|
||||
tokens.add(token);
|
||||
return true;
|
||||
},
|
||||
),
|
||||
)
|
||||
..write(attributes)
|
||||
..write('>');
|
||||
// 그림은 주소가 남았을 때만 보인다 — 남긴 주소는 늘 맨 앞에 쓴다([_attributesOf])
|
||||
if (!visible &&
|
||||
(_drawnWithoutText.contains(name) ||
|
||||
(name == 'img' && attributes.startsWith(' src=')))) {
|
||||
visible = true;
|
||||
}
|
||||
if (_voidTags.contains(name)) continue;
|
||||
stack
|
||||
..add('</$name>')
|
||||
@@ -374,6 +406,7 @@ SanitizedMailBody sanitizeMailHtml(String html, {required bool imagesShown}) {
|
||||
return SanitizedMailBody(
|
||||
fragment: out.toString(),
|
||||
proxiedTokens: List<String>.unmodifiable(tokens),
|
||||
hasVisibleContent: visible,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -381,7 +414,7 @@ const String _htmlNamespace = 'http://www.w3.org/1999/xhtml';
|
||||
|
||||
Iterable<dom.Node> _reversed(List<dom.Node> nodes) => nodes.reversed;
|
||||
|
||||
/// 남길 속성을 ` 이름="값"` 꼴로 — 그림은 `src` 를, 링크는 `href` 를 맨 앞에 쓴다.
|
||||
/// 남길 속성을 ` 이름="값"` 꼴로 — 그림은 `src` 를, 링크는 `href` 를 맨 앞에 쓴다(보이는 그림인지 가르기 · [resolveProxiedImages] 가 이 모양에 기댄다).
|
||||
/// [onProxied] 는 남길 중계 그림이면 참(한 통의 한도를 넘으면 거짓 — 그 그림은 주소를 지운다)
|
||||
String _attributesOf(
|
||||
dom.Element element,
|
||||
|
||||
@@ -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';
|
||||
@@ -489,9 +489,7 @@ class _MessageComposePageState extends ConsumerState<MessageComposePage> {
|
||||
focusNode: _bodyFocus,
|
||||
enabled: enabled,
|
||||
error: lengthError == ComposeLengthError.body,
|
||||
minHeight: quote != null || signature != null
|
||||
? AppSize.composeBodyMinHeightWithQuote
|
||||
: AppSize.composeBodyMinHeight,
|
||||
minHeight: composeBodyMinHeightOf(hasQuote: quote != null),
|
||||
),
|
||||
if (lengthError == ComposeLengthError.body)
|
||||
ComposeAlert(
|
||||
@@ -556,6 +554,12 @@ class _MessageComposePageState extends ConsumerState<MessageComposePage> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 본문 칸의 최소 높이 — 원본 메일(인용)이 아래에 있으면(답장 · 전달) 낮게(150), 없으면(새 메일 등) 서명이 있어도 높게(240).
|
||||
/// 보드 `App1Compose` 의 「새 메일 · 받는 사람 추천」(서명 있음 — 240)과 「답장 · 참조 펼침 · 첨부 · 원본 접힘」(서명 · 원본 메일 — 150)
|
||||
double composeBodyMinHeightOf({required bool hasQuote}) => hasQuote
|
||||
? AppSize.composeBodyMinHeightWithQuote
|
||||
: AppSize.composeBodyMinHeight;
|
||||
|
||||
/// 올리지 못한 고른 파일의 수
|
||||
int failedUploadsOf(ComposeState state) => state.attachments
|
||||
.where(
|
||||
@@ -627,7 +631,6 @@ class _SubjectRow extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ThemeData theme = Theme.of(context);
|
||||
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
|
||||
final TextStyle? style = theme.textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: AppTypography.semiBold,
|
||||
);
|
||||
@@ -653,11 +656,13 @@ class _SubjectRow extends StatelessWidget {
|
||||
Utf16LengthLimitFormatter(kComposeMaxSubject),
|
||||
],
|
||||
style: style,
|
||||
decoration: InputDecoration.collapsed(
|
||||
// 테두리는 칸 줄(ComposeFieldRow)의 아래 선뿐이다 — 테마의 입력칸 테두리를 물려받지 않게
|
||||
// 안내 글은 보드의 placeholder 색(#8A94A3 — 테마의 outline, 검색 칸 안내 글과 같다)
|
||||
decoration: borderlessInputDecoration(
|
||||
hintText: MailComposeTexts.subjectHint,
|
||||
hintStyle: style?.copyWith(
|
||||
fontWeight: AppTypography.regular,
|
||||
color: extra.textMuted,
|
||||
color: theme.colorScheme.outline,
|
||||
),
|
||||
),
|
||||
onSubmitted: (String _) => onSubmitted(),
|
||||
@@ -669,7 +674,7 @@ class _SubjectRow extends StatelessWidget {
|
||||
}
|
||||
|
||||
/// 본문 칸 — 글만(서식 편집은 다음 판). 비어 있는 아래쪽을 눌러도 칸에 초점이 간다.
|
||||
/// 아래에 서명 · 원본 메일이 있으면 낮게(150), 없으면 높게(240) 둔다(보드)
|
||||
/// 아래에 원본 메일이 있으면 낮게(150), 없으면 서명이 있어도 높게(240) 둔다(보드 — [composeBodyMinHeightOf])
|
||||
class _BodyField extends StatelessWidget {
|
||||
const _BodyField({
|
||||
required this.controller,
|
||||
@@ -718,9 +723,9 @@ class _BodyField extends StatelessWidget {
|
||||
textInputAction: TextInputAction.newline,
|
||||
maxLines: null,
|
||||
style: style,
|
||||
decoration: InputDecoration.collapsed(
|
||||
decoration: borderlessInputDecoration(
|
||||
hintText: MailComposeTexts.bodyHint,
|
||||
hintStyle: style?.copyWith(color: extra.textMuted),
|
||||
hintStyle: style?.copyWith(color: theme.colorScheme.outline),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -749,7 +754,7 @@ class _ComposeError extends StatelessWidget {
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Center(
|
||||
child: MailListError(
|
||||
child: LoadErrorView(
|
||||
error: error,
|
||||
onRetry: onRetry,
|
||||
onLeave: onLeave,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:arasystem_app/core/api/api_exception.dart';
|
||||
import 'package:arasystem_app/core/api/dio_provider.dart';
|
||||
@@ -22,18 +23,19 @@ 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';
|
||||
|
||||
/// 머리(제목 · 보낸 사람 · 안내 줄 · 첨부)가 차지할 수 있는 최대 높이 — 남은 자리의 이만큼. 넘으면 머리 안에서 스크롤한다
|
||||
/// 머리(제목 · 보낸 사람 · 안내 줄 · 첨부)가 차지할 수 있는 최대 높이 — 남은 자리의 이만큼. 넘으면 머리 안에서 스크롤한다.
|
||||
/// 본문에 보이는 것이 없으면(본문 칸이 없다) 이 제한 없이 화면 전체를 쓴다
|
||||
const double kReaderHeadMaxFraction = 0.5;
|
||||
|
||||
/// 메일 읽기 — 앱 보드 `App1Reader` (앱 디자인 README 「읽기」). 탭 막대 없는 전체 화면이다.
|
||||
@@ -42,6 +44,8 @@ const double kReaderHeadMaxFraction = 0.5;
|
||||
/// 붙잡힌 메일(보내기 취소를 기다림 · 예약)에는 이동 · 스팸이 없고 [삭제]는 확인 뒤 발송을 취소하고 휴지통으로.
|
||||
/// - 머리는 앱 화면, 본문은 WebView(`MailBodyView`) — 머리 아래 남은 자리를 다 쓰고 본문은 그 안에서 스크롤한다.
|
||||
/// 머리가 길면(펼친 받는 사람 · 첨부 여럿) 남은 자리의 절반까지만 쓰고 머리 안에서 스크롤한다.
|
||||
/// **본문에 보이는 것이 없으면**(첨부만 있는 메일 등 — 상태의 `bodyEmpty`) 본문 칸을 두지 않고 머리가 화면 전체를 한 스크롤로 쓴다
|
||||
/// (2026-11-12 — 아래 절반이 빈 칸으로 남고 첨부 목록이 위 절반 안에서 잘렸다).
|
||||
/// - **첨부 목록은 머리 안(안내 줄 아래 · 본문 위)이다** — 보드는 본문 아래지만, 본문이 WebView 안에서 스크롤하므로 아래에 두면
|
||||
/// 늘 화면 아래 자리를 차지한다(쓰기 화면처럼 위에 둔다 — 앱에서 정함).
|
||||
/// - 아래 행동 막대: 답장 · 전체 답장 · 전달 — 쓰기 화면으로 그 메일 id 를 넘긴다(`MailRoutes.composeReply` 등). 예약한 메일에는 없다(웹과 같다).
|
||||
@@ -349,7 +353,8 @@ List<ReaderMenuItem> readerMenuOf(
|
||||
];
|
||||
}
|
||||
|
||||
/// 받은 메일 — 머리(남은 자리의 절반까지 · 넘으면 그 안에서 스크롤) + 본문 WebView(남은 자리 전부)
|
||||
/// 받은 메일 — 머리(남은 자리의 절반까지 · 넘으면 그 안에서 스크롤) + 본문 WebView(남은 자리 전부).
|
||||
/// 본문에 보이는 것이 없으면 본문 칸 없이 머리만(화면 전체까지 · 넘으면 그 안에서 스크롤)
|
||||
class _ReaderContent extends ConsumerWidget {
|
||||
const _ReaderContent({
|
||||
required this.messageId,
|
||||
@@ -383,16 +388,31 @@ class _ReaderContent extends ConsumerWidget {
|
||||
final String? myEmail = ref.watch(mailAccountProvider);
|
||||
final List<MessageAttachment> attachments = detail.listedAttachments;
|
||||
final String? document = reader.document;
|
||||
// 본문 칸 — 보이는 것이 없는 본문이면 두지 않는다. 문서를 만드는 동안(document 가 null)은 아직 모르므로 지금처럼 진행 줄을 둔다
|
||||
final bool bodyShown = !reader.bodyEmpty;
|
||||
return LayoutBuilder(
|
||||
builder: (BuildContext context, BoxConstraints constraints) {
|
||||
// 본문 칸이 없으면 머리가 화면 전체를 쓴다 — 그림 받는 진행 막대가 머리 아래에 들 자리만 남긴다
|
||||
final double headMaxHeight = bodyShown
|
||||
? constraints.maxHeight * kReaderHeadMaxFraction
|
||||
: math.max(
|
||||
0,
|
||||
constraints.maxHeight -
|
||||
(reader.imagesLoading ? AppSize.progressBarHeight : 0),
|
||||
);
|
||||
// 머리가 화면 아래 끝까지 닿으면(본문 칸이 없다) 시스템 내비게이션 막대 몫을 머리 끝에 더한다 — 마지막 첨부가 그 밑에 남지 않게.
|
||||
// 아래 행동 막대가 있으면 Scaffold 가 이 값을 이미 0 으로 만든다(예약 메일처럼 막대가 없을 때만 남는다)
|
||||
final double headBottomInset = bodyShown
|
||||
? 0
|
||||
: MediaQuery.paddingOf(context).bottom;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: <Widget>[
|
||||
// 머리는 두 짜임에서 같은 자리의 같은 위젯이다 — 빈 본문에서 [이미지 보기]로 본문 칸이 생겨도 펼친 받는 사람이 그대로 남는다
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: constraints.maxHeight * kReaderHeadMaxFraction,
|
||||
),
|
||||
constraints: BoxConstraints(maxHeight: headMaxHeight),
|
||||
child: SingleChildScrollView(
|
||||
padding: EdgeInsets.only(bottom: headBottomInset),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: <Widget>[
|
||||
@@ -438,14 +458,15 @@ class _ReaderContent extends ConsumerWidget {
|
||||
minHeight: AppSize.progressBarHeight,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: document == null
|
||||
? const _BodyLoading()
|
||||
: MailBodyView(
|
||||
document: document,
|
||||
onNavigation: onNavigation,
|
||||
),
|
||||
),
|
||||
if (bodyShown)
|
||||
Expanded(
|
||||
child: document == null
|
||||
? const _BodyLoading()
|
||||
: MailBodyView(
|
||||
document: document,
|
||||
onNavigation: onNavigation,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
@@ -533,7 +554,7 @@ class _ReaderError extends StatelessWidget {
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Center(
|
||||
child: MailListError(
|
||||
child: LoadErrorView(
|
||||
error: error,
|
||||
onRetry: onRetry,
|
||||
onLeave: onLeave,
|
||||
|
||||
@@ -101,6 +101,7 @@ messageReaderProvider = NotifierProvider.autoDispose
|
||||
///
|
||||
/// - 열면 안 읽은 메일을 읽음으로 바꾼다(화면 · 메일함 개수를 먼저 — 실패하면 알리지 않고 되돌린다).
|
||||
/// - 본문은 두 번째 정화 뒤 CSP 를 건 문서로 만든다(`mail_html.dart` — 짧고 얕은 본문만 그 자리에서, 길거나 깊은 본문은 다른 isolate 에서).
|
||||
/// 정화가 함께 낸 "보이는 것이 없다"([MessageReaderState.bodyEmpty] — 첨부만 있는 메일 등)도 같은 차례에 싣는다 — 화면은 본문 칸을 두지 않는다.
|
||||
/// 외부 이미지를 보이는 본문이면(설정 · [이미지 보기]) 중계 그림을 Dio 로 받아(한 번에 [kMailImageConcurrency] 개 · 한도 `mail_images.dart`)
|
||||
/// `data:` 로 넣고 문서를 한 번 더 만든다. 그림 받기는 화면이 사라지거나 본문이 바뀌면 끊는다(끊긴 그림은 숨기고 알리지 않는다).
|
||||
/// - 옮기기 · 스팸 · 휴지통은 응답을 받은 뒤 목록으로 돌아가고(부르는 쪽이), 알림에 [되돌리기](원래 메일함 — 스팸은 표시도 반대로).
|
||||
@@ -279,6 +280,8 @@ class MessageReaderController extends Notifier<MessageReaderState> {
|
||||
state = state.copyWith(
|
||||
document: _document(),
|
||||
bodyOmitted: body.htmlOmitted,
|
||||
// 보이는 것이 없는 본문이면 화면이 본문 칸을 두지 않는다 — 판정은 정화가 함께 냈다
|
||||
bodyEmpty: !body.hasVisibleContent,
|
||||
imagesLoading: hasImages,
|
||||
);
|
||||
if (!hasImages) return;
|
||||
|
||||
@@ -38,6 +38,7 @@ class MessageReaderState {
|
||||
this.error,
|
||||
this.document,
|
||||
this.bodyOmitted = false,
|
||||
this.bodyEmpty = false,
|
||||
this.imagesLoading = false,
|
||||
this.busy,
|
||||
this.subjectHighlight,
|
||||
@@ -52,6 +53,10 @@ class MessageReaderState {
|
||||
/// WebView 에 넣을 문서(정화 · CSP 를 마쳤다) — 만들기 전이면 null
|
||||
final String? document;
|
||||
|
||||
/// 보여 줄 본문에 보이는 것이 없다(첨부만 있는 메일 등 — 정화가 함께 낸 판정 `SanitizedMailBody.hasVisibleContent`).
|
||||
/// 화면은 본문 칸(WebView)을 두지 않고 머리(제목 · 안내 줄 · 첨부)를 화면 전체에서 한 스크롤로 보인다. 문서를 만들기 전에는 거짓
|
||||
final bool bodyEmpty;
|
||||
|
||||
/// 서식 본문이 앱의 정화 한도를 넘어 글자 본문을 보인다 — 서버의 생략(`isHtmlBodyOmitted`)과 같은 안내를 보인다
|
||||
final bool bodyOmitted;
|
||||
|
||||
@@ -75,6 +80,7 @@ class MessageReaderState {
|
||||
MessageDetail? detail,
|
||||
String? document,
|
||||
bool? bodyOmitted,
|
||||
bool? bodyEmpty,
|
||||
bool? imagesLoading,
|
||||
MessageReaderBusy? busy,
|
||||
bool clearBusy = false,
|
||||
@@ -85,6 +91,7 @@ class MessageReaderState {
|
||||
error: error,
|
||||
document: document ?? this.document,
|
||||
bodyOmitted: bodyOmitted ?? this.bodyOmitted,
|
||||
bodyEmpty: bodyEmpty ?? this.bodyEmpty,
|
||||
imagesLoading: imagesLoading ?? this.imagesLoading,
|
||||
busy: clearBusy ? null : (busy ?? this.busy),
|
||||
subjectHighlight: identical(subjectHighlight, _unset)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:arasystem_app/core/people/address_input.dart';
|
||||
import 'package:arasystem_app/core/people/people_texts.dart';
|
||||
import 'package:arasystem_app/core/people/recipient_match.dart';
|
||||
@@ -192,6 +194,10 @@ class _ComposeRecipientFieldState extends State<ComposeRecipientField> {
|
||||
error: invalid,
|
||||
onTap: widget.enabled ? widget.focusNode.requestFocus : null,
|
||||
trailing: widget.trailing,
|
||||
// 칩이 있으면 첫 줄은 칩 줄 — 여러 줄이어도 칸 이름 · [참조 펼치기]가 첫 칩 줄에 맞는다
|
||||
firstLineHeight: widget.tokens.isEmpty
|
||||
? null
|
||||
: AppSize.recipientChipHeight,
|
||||
child: LayoutBuilder(
|
||||
builder: (BuildContext context, BoxConstraints constraints) {
|
||||
return Wrap(
|
||||
@@ -263,7 +269,8 @@ class _ComposeRecipientFieldState extends State<ComposeRecipientField> {
|
||||
smartQuotesType: SmartQuotesType.disabled,
|
||||
inputFormatters: const <TextInputFormatter>[_LineBreaksToCommas()],
|
||||
style: theme.textTheme.bodyLarge,
|
||||
decoration: const InputDecoration.collapsed(hintText: ''),
|
||||
// 테두리는 칸 줄(ComposeFieldRow)의 아래 선뿐이다 — 테마의 입력칸 테두리를 물려받지 않게
|
||||
decoration: borderlessInputDecoration(hintText: ''),
|
||||
onChanged: widget.onChanged,
|
||||
onEditingComplete: _onNext,
|
||||
),
|
||||
@@ -342,7 +349,12 @@ class _LineBreaksToCommas extends TextInputFormatter {
|
||||
}
|
||||
}
|
||||
|
||||
/// 쓰기 화면의 칸 줄 — 왼쪽 칸 이름(72) · 가운데 칸 · 오른쪽 단추, 아래 선(초점이 있으면 청록 2) (보드 `App1Compose`)
|
||||
/// 쓰기 화면의 칸 줄 — 왼쪽 칸 이름(72 · 글자 15) · 간격 6 · 가운데 칸 · 오른쪽 단추, 아래 선(초점이 있으면 청록 2)
|
||||
/// (보드 `App1Compose` — 최소 높이 56 · 여백 위 4 · 오른쪽 4 · 아래 4 · 왼쪽 16).
|
||||
///
|
||||
/// 칸 이름과 오른쪽 단추는 **첫 줄**([lineHeight] — 최소 높이에서 위아래 여백을 뺀 48)의 세로 가운데에 둔다 —
|
||||
/// 받는 사람 칩이 여러 줄이어도 첫 줄에 남는다(줄 전체의 가운데로 내려가지 않게 — 2026-11-12). 칸이 한 줄이면 줄의 가운데 그대로다.
|
||||
/// 아래 선은 자리를 차지하지 않게 그린다 — 초점 · 오류로 굵어져도 칸이 움직이지 않는다(보드의 inset 그림자와 같다)
|
||||
class ComposeFieldRow extends StatelessWidget {
|
||||
const ComposeFieldRow({
|
||||
required this.label,
|
||||
@@ -351,9 +363,14 @@ class ComposeFieldRow extends StatelessWidget {
|
||||
this.error = false,
|
||||
this.trailing,
|
||||
this.onTap,
|
||||
this.firstLineHeight,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// 첫 줄의 높이 — 줄의 최소 높이(56)에서 위아래 여백(4)을 뺀 자리
|
||||
static const double lineHeight =
|
||||
AppSize.composeRowMinHeight - AppSpacing.s4 * 2;
|
||||
|
||||
final String label;
|
||||
final Widget child;
|
||||
final bool focused;
|
||||
@@ -363,12 +380,20 @@ class ComposeFieldRow extends StatelessWidget {
|
||||
final Widget? trailing;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
/// 칸 안 첫 줄의 높이(받는 사람 칩 40) — 주면 칸을 위아래로 ([lineHeight] − 이 값) / 2 씩 띄워,
|
||||
/// 칩이 여러 줄이어도 첫 칩 줄이 칸 이름 · 오른쪽 단추와 같은 높이에 온다(한 줄이면 줄을 꼭 채워 지금처럼 가운데)
|
||||
final double? firstLineHeight;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ThemeData theme = Theme.of(context);
|
||||
final ColorScheme scheme = theme.colorScheme;
|
||||
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
|
||||
final Widget? action = trailing;
|
||||
final double? inner = firstLineHeight;
|
||||
final double inset = inner == null
|
||||
? 0
|
||||
: math.max(0, (lineHeight - inner) / 2);
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTap: onTap,
|
||||
@@ -376,13 +401,13 @@ class ComposeFieldRow extends StatelessWidget {
|
||||
constraints: const BoxConstraints(
|
||||
minHeight: AppSize.composeRowMinHeight,
|
||||
),
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.s16,
|
||||
AppSpacing.s4,
|
||||
action == null ? AppSpacing.s16 : AppSpacing.s4,
|
||||
AppSpacing.s4,
|
||||
AppSpacing.s4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
foregroundDecoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: error
|
||||
@@ -395,20 +420,32 @@ class ComposeFieldRow extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
SizedBox(
|
||||
width: AppSize.composeLabelWidth,
|
||||
child: ExcludeSemantics(
|
||||
child: Text(
|
||||
label,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: extra.textMuted,
|
||||
_FirstLine(
|
||||
child: SizedBox(
|
||||
width: AppSize.composeLabelWidth,
|
||||
child: ExcludeSemantics(
|
||||
child: Text(
|
||||
label,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: extra.textMuted,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(child: child),
|
||||
?action,
|
||||
const SizedBox(width: AppSpacing.s6),
|
||||
Expanded(
|
||||
child: _FirstLine(
|
||||
stretch: true,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: inset),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (action != null) _FirstLine(child: action),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -416,6 +453,31 @@ class ComposeFieldRow extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 칸 줄의 첫 줄 자리 — [ComposeFieldRow.lineHeight] 안의 세로 가운데에 둔다. 더 높으면(칩이 여러 줄 · 큰 글자) 위에서부터
|
||||
class _FirstLine extends StatelessWidget {
|
||||
const _FirstLine({required this.child, this.stretch = false});
|
||||
|
||||
final Widget child;
|
||||
|
||||
/// 폭을 다 쓴다(가운데 칸)
|
||||
final bool stretch;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ConstrainedBox(
|
||||
constraints: const BoxConstraints(minHeight: ComposeFieldRow.lineHeight),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: stretch
|
||||
? CrossAxisAlignment.stretch
|
||||
: CrossAxisAlignment.start,
|
||||
children: <Widget>[child],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 받는 사람 칩 — 이름(없으면 주소) · ✕. 형식이 틀린 주소는 빨갛게
|
||||
class _RecipientChip extends StatelessWidget {
|
||||
const _RecipientChip({required this.token, required this.onRemove});
|
||||
@@ -427,7 +489,6 @@ class _RecipientChip extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final ThemeData theme = Theme.of(context);
|
||||
final ColorScheme scheme = theme.colorScheme;
|
||||
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
|
||||
final String? name = token.address.name?.trim();
|
||||
final String shown = !token.valid
|
||||
? token.label
|
||||
@@ -470,7 +531,8 @@ class _RecipientChip extends StatelessWidget {
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
iconSize: AppSize.iconTiny,
|
||||
color: token.valid ? extra.textStrong : scheme.error,
|
||||
// 보드의 ✕ #4A5363 — 보조 글자색(onSurfaceVariant)
|
||||
color: token.valid ? scheme.onSurfaceVariant : scheme.error,
|
||||
tooltip: MailComposeTexts.remove(shown),
|
||||
onPressed: onRemove,
|
||||
icon: const Icon(Icons.close),
|
||||
|
||||
@@ -18,6 +18,7 @@ import 'package:webview_flutter_android/webview_flutter_android.dart';
|
||||
/// - 토큰은 WebView 에 가지 않는다(Bearer 는 Dio 만 — 그림 · 첨부는 앱이 받는다).
|
||||
///
|
||||
/// 높이: 머리(앱 화면) 아래 남은 자리를 다 쓰고 본문은 이 안에서 스크롤한다(앱 디자인 결정 — 스크립트가 꺼져 내용 높이를 잴 수 없다).
|
||||
/// 그래서 보이는 것이 없는 본문(첨부만 있는 메일 등)이면 읽기 화면이 이 칸을 아예 두지 않는다(`SanitizedMailBody.hasVisibleContent`).
|
||||
class MailBodyView extends StatefulWidget {
|
||||
const MailBodyView({
|
||||
required this.document,
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -94,8 +94,8 @@ class MailSearchBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
Utf16LengthLimitFormatter(kMailSearchMaxLength),
|
||||
],
|
||||
style: style,
|
||||
decoration: InputDecoration.collapsed(
|
||||
hintText: null,
|
||||
// 위 막대 안의 칸 — 테마의 입력칸 테두리를 물려받지 않게
|
||||
decoration: borderlessInputDecoration(
|
||||
hint: ExcludeSemantics(
|
||||
child: Text(
|
||||
MailSearchTexts.box,
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+163
@@ -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';
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ publish_to: 'none'
|
||||
# 앱 판 — build-name(1.0.0)은 Android versionName · iOS CFBundleShortVersionString,
|
||||
# build-number(+1)는 Android versionCode · iOS CFBundleVersion 이 된다.
|
||||
# 서버에 보내는 User-Agent 의 판(ARASYSTEM-App/<판>)도 build-name 이다 (core/api/user_agent.dart).
|
||||
version: 1.0.0+1
|
||||
version: 1.0.1+2
|
||||
|
||||
environment:
|
||||
# Flutter 3.41.9 의 Dart — 판 고정은 .claude/skills/flutter/SKILL.md "Flutter 판"
|
||||
|
||||
@@ -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,131 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:arasystem_app/core/theme/app_theme.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../support/input_borders.dart';
|
||||
|
||||
/// 테두리 없는 입력칸 꾸밈 — 칸 줄이 아래 선 · 초점을 따로 그리는 칸(쓰기 화면 · 검색 막대)이 앱 테마의 둥근 테두리를 물려받지 않는다
|
||||
void main() {
|
||||
final ThemeData theme = AppTheme.light();
|
||||
|
||||
test(
|
||||
'앱 테마를 입혀도 여섯 테두리가 모두 없고 바탕 · 안쪽 여백도 없다 — collapsed 는 테마의 둥근 테두리를 물려받는다',
|
||||
() {
|
||||
final InputDecoration bare = borderlessInputDecoration(
|
||||
hintText: '제목을 입력해 주세요',
|
||||
).applyDefaults(theme.inputDecorationTheme);
|
||||
for (final InputBorder? border in <InputBorder?>[
|
||||
bare.border,
|
||||
bare.enabledBorder,
|
||||
bare.focusedBorder,
|
||||
bare.disabledBorder,
|
||||
bare.errorBorder,
|
||||
bare.focusedErrorBorder,
|
||||
]) {
|
||||
expect(border, InputBorder.none);
|
||||
}
|
||||
expect(bare.filled, isFalse);
|
||||
expect(bare.contentPadding, EdgeInsets.zero);
|
||||
expect(bare.isCollapsed, isTrue);
|
||||
expect(bare.hintText, '제목을 입력해 주세요');
|
||||
|
||||
// 까닭 — collapsed 는 border 만 없애고 비워 둔 칸을 TextField 가 테마의 둥근 테두리로 채운다
|
||||
final InputDecoration collapsed = const InputDecoration.collapsed(
|
||||
hintText: '제목을 입력해 주세요',
|
||||
).applyDefaults(theme.inputDecorationTheme);
|
||||
expect(collapsed.border, InputBorder.none);
|
||||
expect(collapsed.enabledBorder, isA<OutlineInputBorder>());
|
||||
expect(collapsed.focusedBorder, isA<OutlineInputBorder>());
|
||||
},
|
||||
);
|
||||
|
||||
/// 앱 테마로 칸 하나를 그린다 — [enabled] 가 거짓이면 꺼진 칸
|
||||
Future<FocusNode> pumpField(
|
||||
WidgetTester tester,
|
||||
InputDecoration decoration, {
|
||||
bool enabled = true,
|
||||
}) async {
|
||||
final FocusNode focusNode = FocusNode();
|
||||
addTearDown(focusNode.dispose);
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: theme,
|
||||
home: Scaffold(
|
||||
body: TextField(
|
||||
key: const ValueKey<String>('field'),
|
||||
focusNode: focusNode,
|
||||
enabled: enabled,
|
||||
decoration: decoration,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
return focusNode;
|
||||
}
|
||||
|
||||
final Finder field = find.byKey(const ValueKey<String>('field'));
|
||||
|
||||
testWidgets('앱 테마로 그린 칸 — 평소 · 초점 · 꺼짐 어느 때나 InputDecorator 가 받는 테두리가 없다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
final FocusNode focusNode = await pumpField(
|
||||
tester,
|
||||
borderlessInputDecoration(hintText: '제목'),
|
||||
);
|
||||
InputDecorator decorator = expectBorderlessInput(
|
||||
tester,
|
||||
field,
|
||||
reason: '평소',
|
||||
);
|
||||
expect(decorator.isFocused, isFalse);
|
||||
expect(decorator.decoration.enabled, isTrue);
|
||||
|
||||
focusNode.requestFocus();
|
||||
await tester.pump();
|
||||
decorator = expectBorderlessInput(tester, field, reason: '초점');
|
||||
expect(decorator.isFocused, isTrue);
|
||||
|
||||
await pumpField(tester, borderlessInputDecoration(), enabled: false);
|
||||
decorator = expectBorderlessInput(tester, field, reason: '꺼짐');
|
||||
expect(decorator.decoration.enabled, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('견줌 — collapsed 로 그린 칸은 평소에 회색, 초점에 청록 둥근 테두리를 그린다(이 시험이 잡는 것)', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
final FocusNode focusNode = await pumpField(
|
||||
tester,
|
||||
const InputDecoration.collapsed(hintText: '제목'),
|
||||
);
|
||||
InputDecorator decorator() => tester.widget<InputDecorator>(
|
||||
find.descendant(of: field, matching: find.byType(InputDecorator)),
|
||||
);
|
||||
expect(isDrawnInputBorder(shownInputBorder(decorator())), isTrue);
|
||||
focusNode.requestFocus();
|
||||
await tester.pump();
|
||||
final InputBorder? focused = shownInputBorder(decorator());
|
||||
expect(isDrawnInputBorder(focused), isTrue);
|
||||
expect(focused!.borderSide.color, theme.colorScheme.primary);
|
||||
});
|
||||
|
||||
test(
|
||||
'lib 의 입력칸은 InputDecoration.collapsed 를 쓰지 않는다 — 테두리 없는 칸은 borderlessInputDecoration',
|
||||
() {
|
||||
final List<String> found = <String>[];
|
||||
for (final FileSystemEntity entity in Directory(
|
||||
'lib',
|
||||
).listSync(recursive: true)) {
|
||||
if (entity is! File || !entity.path.endsWith('.dart')) continue;
|
||||
final List<String> lines = entity.readAsLinesSync();
|
||||
for (int index = 0; index < lines.length; index++) {
|
||||
if (lines[index].contains('InputDecoration.collapsed(')) {
|
||||
found.add('${entity.path}:${index + 1}');
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(found, isEmpty);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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,111 @@
|
||||
import 'package:arasystem_app/core/utils/visible_text.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
/// 보이는 글자가 있는가 — 공백 · 제어 문자 · 보이지 않는 글자만 있으면 빈 글이다
|
||||
void main() {
|
||||
/// 글자 코드로 만든 글 — 소스에 보이지 않는 글자를 두지 않는다
|
||||
String chars(List<int> codes) => String.fromCharCodes(codes);
|
||||
|
||||
test('Dart trim() 이 지우는 공백은 모두 보이지 않는다', () {
|
||||
int blanks = 0;
|
||||
for (int code = 0; code <= 0xFFFF; code++) {
|
||||
// 홀로 선 서로게이트는 글자가 아니다
|
||||
if (code >= 0xD800 && code <= 0xDFFF) continue;
|
||||
final String char = String.fromCharCode(code);
|
||||
if (char.trim().isNotEmpty) continue;
|
||||
blanks++;
|
||||
expect(
|
||||
hasVisibleText(char),
|
||||
isFalse,
|
||||
reason: 'U+${code.toRadixString(16).toUpperCase()}',
|
||||
);
|
||||
}
|
||||
// 유니코드 White_Space 25자 + BOM — 빈칸 · 탭 · 줄바꿈 · nbsp · 너비가 다른 빈칸 11자 · 줄 · 문단 구분 · 전각 빈칸 등
|
||||
expect(blanks, 26);
|
||||
});
|
||||
|
||||
test('빈 글 · 공백(nbsp · 전각 빈칸 · 줄 · 문단 구분 · BOM 등)뿐인 글은 보이는 것이 없다', () {
|
||||
expect(hasVisibleText(''), isFalse);
|
||||
expect(hasVisibleText(' \t\r\n'), isFalse);
|
||||
for (final int code in <int>[
|
||||
0x00A0, // nbsp
|
||||
0x0085, // NEL
|
||||
0x1680,
|
||||
0x2002,
|
||||
0x200A,
|
||||
0x2028,
|
||||
0x2029,
|
||||
0x202F,
|
||||
0x205F,
|
||||
0x3000, // 전각 빈칸
|
||||
0xFEFF, // BOM
|
||||
]) {
|
||||
expect(hasVisibleText(chars(<int>[0x20, code, 0x0A])), isFalse);
|
||||
}
|
||||
});
|
||||
|
||||
test(
|
||||
'제어 문자 · 보이지 않는 글자(제로폭 · 이음 · 방향 표시 · 이체 선택자 · 한글 채움 · 태그 · 점자 빈칸)도 보이지 않는다',
|
||||
() {
|
||||
for (final int code in <int>[
|
||||
0x0000,
|
||||
0x001F,
|
||||
0x007F,
|
||||
0x009F,
|
||||
0x00AD, // soft hyphen
|
||||
0x034F, // CGJ
|
||||
0x061C,
|
||||
0x115F, // 한글 초성 채움
|
||||
0x1160, // 한글 중성 채움
|
||||
0x180E,
|
||||
0x200B, // 제로폭 빈칸
|
||||
0x200C, // ZWNJ
|
||||
0x200D, // ZWJ
|
||||
0x200E, // LRM
|
||||
0x200F, // RLM
|
||||
0x202A,
|
||||
0x202E,
|
||||
0x2060, // WORD JOINER
|
||||
0x2066,
|
||||
0x2069,
|
||||
0x2800, // 점자 빈칸
|
||||
0x3164, // 한글 채움 문자
|
||||
0xFE0F, // 이체 선택자
|
||||
0xFFA0, // 반각 한글 채움 문자
|
||||
0x1BCA0,
|
||||
0x1D173,
|
||||
0xE0001, // 태그 글자
|
||||
0xE0041,
|
||||
0xE01EF, // 이체 선택자 보충
|
||||
]) {
|
||||
expect(
|
||||
hasVisibleText(chars(<int>[code, code])),
|
||||
isFalse,
|
||||
reason: 'U+${code.toRadixString(16).toUpperCase()}',
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test('글자 · 기호 · 숫자 · 이모지가 하나라도 있으면 보인다', () {
|
||||
expect(hasVisibleText('가'), isTrue);
|
||||
expect(hasVisibleText('a'), isTrue);
|
||||
expect(hasVisibleText('.'), isTrue);
|
||||
expect(hasVisibleText('~'), isTrue);
|
||||
expect(hasVisibleText('0'), isTrue);
|
||||
expect(hasVisibleText(chars(<int>[0x1F600])), isTrue);
|
||||
// 이음(ZWJ)으로 묶은 이모지 — 이음 말고 그림 글자가 보인다
|
||||
expect(hasVisibleText(chars(<int>[0x1F468, 0x200D, 0x1F469])), isTrue);
|
||||
// 보이지 않는 글자들 뒤에 글자 하나
|
||||
expect(
|
||||
hasVisibleText(chars(<int>[0x00A0, 0x200B, 0xFEFF, 0x3164, 0x41])),
|
||||
isTrue,
|
||||
);
|
||||
// 보이지 않는 글자의 범위 바로 옆 글자는 보인다
|
||||
expect(hasVisibleText(chars(<int>[0x00A1])), isTrue); // ¡
|
||||
expect(hasVisibleText(chars(<int>[0x2010])), isTrue); // 붙임표
|
||||
expect(hasVisibleText(chars(<int>[0x2801])), isTrue); // 점자 점 하나
|
||||
expect(hasVisibleText(chars(<int>[0x3163])), isTrue); // ㅣ
|
||||
expect(hasVisibleText(chars(<int>[0x20000])), isTrue); // 한자 확장 B
|
||||
});
|
||||
}
|
||||
@@ -372,6 +372,141 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('본문에 보이는 것이 있는가 (없으면 읽기 화면에 본문 칸이 없다)', () {
|
||||
bool visibleHtml(String html, {String? text, bool imagesShown = false}) =>
|
||||
mailBodyFragment(
|
||||
htmlBody: html,
|
||||
textBody: text,
|
||||
imagesShown: imagesShown,
|
||||
).hasVisibleContent;
|
||||
|
||||
bool visibleText(String? text) => mailBodyFragment(
|
||||
htmlBody: null,
|
||||
textBody: text,
|
||||
imagesShown: false,
|
||||
).hasVisibleContent;
|
||||
|
||||
test('빈 줄 · nbsp · 제로폭 글자뿐인 서식 본문은 비었다', () {
|
||||
expect(visibleHtml('<div><br></div>'), isFalse);
|
||||
expect(visibleHtml('<p> </p>'), isFalse);
|
||||
// Gmail · Outlook 이 본문 없이 보낸 꼴 — 모르는 태그(o:p)는 지우고 안의 nbsp 만 남는다
|
||||
expect(visibleHtml('<div dir="ltr"><br></div><div><br></div>'), isFalse);
|
||||
expect(
|
||||
visibleHtml('<p class="MsoNormal"><o:p> </o:p></p>'),
|
||||
isFalse,
|
||||
);
|
||||
expect(
|
||||
visibleHtml(
|
||||
'<div style="font-family:Pretendard;color:#1F2733"><p></p></div>',
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
// 제로폭 빈칸 · BOM · WORD JOINER · 한글 채움 문자 · 점자 빈칸
|
||||
expect(
|
||||
visibleHtml('<p>​⁠ㅤ⠀</p>'),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('내용째 버리는 것 · 주석 · 주소를 지운 그림은 보이지 않는다', () {
|
||||
expect(
|
||||
visibleHtml(
|
||||
'<style>p{color:red}</style><!-- 주석 --><script>alert(1)</script>'
|
||||
'<title>제목</title>',
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
expect(
|
||||
visibleHtml(
|
||||
'<p><svg><text>그림 글</text></svg><template><b>숨은 글</b></template></p>',
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
// 막은 바깥 그림 · 외부 이미지를 보이지 않을 때의 중계 주소 — 주소를 지워 문서가 숨긴다(alt 도 보이지 않는다)
|
||||
expect(
|
||||
visibleHtml('<img src="https://t.example/p.png" width="600">'),
|
||||
isFalse,
|
||||
);
|
||||
expect(visibleHtml('<img src="/api/mail-images/t1" alt="배너">'), isFalse);
|
||||
});
|
||||
|
||||
test(
|
||||
'꾸밈(style · bgcolor · border)만으로 그린 상자 · 선과 빈 인용의 막대는 보이는 것으로 치지 않는다',
|
||||
() {
|
||||
expect(
|
||||
visibleHtml(
|
||||
'<table border="1" bgcolor="#EEEEEE"><tr>'
|
||||
'<td style="height:20px;background:#0E7C86;border:1px solid #000000"></td>'
|
||||
'</tr></table>',
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
expect(visibleHtml('<blockquote><br></blockquote>'), isFalse);
|
||||
},
|
||||
);
|
||||
|
||||
test('글 · 그림 · 가로줄 · 목록 표시 · 펼침 · 따옴표가 하나라도 있으면 보인다', () {
|
||||
expect(visibleHtml('<p>안녕하세요</p>'), isTrue);
|
||||
expect(visibleHtml('<o:p>모르는 태그 안의 글</o:p>'), isTrue);
|
||||
expect(visibleHtml('<p> </p><p>.</p>'), isTrue);
|
||||
expect(
|
||||
visibleHtml(
|
||||
'<p><br></p><img src="data:image/png;base64,iVBORw0KGgo=">',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
// 외부 이미지를 보일 때의 중계 그림 — 받기 전에도 보이는 것으로 친다
|
||||
expect(
|
||||
visibleHtml('<img src="/api/mail-images/t1">', imagesShown: true),
|
||||
isTrue,
|
||||
);
|
||||
expect(visibleHtml('<hr>'), isTrue);
|
||||
expect(visibleHtml('<ul><li></li></ul>'), isTrue);
|
||||
expect(visibleHtml('<details></details>'), isTrue);
|
||||
expect(visibleHtml('<q></q>'), isTrue);
|
||||
});
|
||||
|
||||
test('글자 본문 — 없거나 공백 · 보이지 않는 글자뿐이면 비었다', () {
|
||||
expect(visibleText(null), isFalse);
|
||||
expect(visibleText(''), isFalse);
|
||||
expect(visibleText(' \n\t\n'), isFalse);
|
||||
expect(
|
||||
visibleText(String.fromCharCodes(<int>[0x00A0, 0x200B, 0x0A, 0xFEFF])),
|
||||
isFalse,
|
||||
);
|
||||
expect(visibleText('본문'), isTrue);
|
||||
});
|
||||
|
||||
test(
|
||||
'고른 본문으로 본다 — 공백뿐인 서식 본문 · 한도를 넘은 서식 본문은 글자 본문, 비어 보이는 서식 본문은 그대로',
|
||||
() {
|
||||
expect(visibleHtml(' \n ', text: ''), isFalse);
|
||||
expect(visibleHtml(' \n ', text: '글자 본문'), isTrue);
|
||||
// 서식 본문이 비어 보여도 글자 본문으로 바꾸지 않는다(웹 displayedHtmlBody 와 같은 본문을 보인다)
|
||||
expect(visibleHtml('<div><br></div>', text: '글자 본문'), isFalse);
|
||||
|
||||
const int depth = 20000;
|
||||
final String deep = '${'<div>' * depth}${'</div>' * depth}';
|
||||
final SanitizedMailBody omitted = mailBodyFragment(
|
||||
htmlBody: deep,
|
||||
textBody: '',
|
||||
imagesShown: false,
|
||||
);
|
||||
expect(omitted.htmlOmitted, isTrue);
|
||||
expect(omitted.hasVisibleContent, isFalse);
|
||||
expect(visibleHtml(deep, text: '글자 본문'), isTrue);
|
||||
},
|
||||
);
|
||||
|
||||
test('깊거나 큰 본문도 되풀이 없이 정화하며 판정한다', () {
|
||||
const int depth = 1500;
|
||||
expect(visibleHtml('${'<div>' * depth}<br>${'</div>' * depth}'), isFalse);
|
||||
expect(visibleHtml('${'<div>' * depth}끝${'</div>' * depth}'), isTrue);
|
||||
// 맨 끝에만 글이 있는 본문
|
||||
expect(visibleHtml('${'<p> </p>' * 20000}<p>끝</p>'), isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('본문 조각 · 문서', () {
|
||||
test('HTML 이 없거나 공백뿐이면 글자 본문을 이스케이프해 보인다', () {
|
||||
final SanitizedMailBody text = mailBodyFragment(
|
||||
|
||||
@@ -28,6 +28,7 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../support/fakes.dart';
|
||||
import '../../support/input_borders.dart';
|
||||
import '../../support/login_fakes.dart';
|
||||
import '../../support/mail_fakes.dart';
|
||||
import '../../support/mail_harness.dart';
|
||||
@@ -187,6 +188,24 @@ void main() {
|
||||
expect(h.mail.searchQueries, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'검색 칸은 테두리를 그리지 않는다 — 초점이 있을 때 · 놓은 뒤(앱 테마의 둥근 테두리를 물려받지 않는다)',
|
||||
(WidgetTester tester) async {
|
||||
await openSearch(tester);
|
||||
expect(
|
||||
expectBorderlessInput(tester, field(), reason: '초점').isFocused,
|
||||
isTrue,
|
||||
);
|
||||
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
expectBorderlessInput(tester, field(), reason: '평소').isFocused,
|
||||
isFalse,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'[검색] → 뼈대("찾는 중") → 결과 — 개수 줄 · 제목 · 미리 보기의 맞은 말이 노랑, 자판을 내린다',
|
||||
(WidgetTester tester) async {
|
||||
|
||||
@@ -32,6 +32,7 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../support/compose_fakes.dart';
|
||||
import '../../support/fakes.dart';
|
||||
import '../../support/input_borders.dart';
|
||||
import '../../support/login_fakes.dart';
|
||||
import '../../support/mail_fakes.dart';
|
||||
import '../../support/mail_harness.dart';
|
||||
@@ -500,6 +501,241 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('입력칸 모양', () {
|
||||
testWidgets(
|
||||
'칸마다 테두리를 그리지 않는다 — 평소 · 초점 · 꺼짐(보내는 중) 모두 칸 줄의 아래 선뿐(앱 테마의 둥근 테두리를 물려받지 않는다)',
|
||||
(WidgetTester tester) async {
|
||||
await start(tester);
|
||||
await open(tester, MailRoutes.compose);
|
||||
await tapTooltip(tester, MailComposeTexts.expandCopies);
|
||||
await addRecipient(tester, 'doyun@x.kr');
|
||||
await tester.enterText(subjectField, '계약');
|
||||
await tester.enterText(bodyField, '안녕하세요');
|
||||
final List<(String, Finder)> fields = <(String, Finder)>[
|
||||
('받는 사람', field(RecipientFieldKind.to)),
|
||||
('참조', field(RecipientFieldKind.cc)),
|
||||
('숨은참조', field(RecipientFieldKind.bcc)),
|
||||
('제목', subjectField),
|
||||
('본문', bodyField),
|
||||
];
|
||||
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
await tester.pumpAndSettle();
|
||||
for (final (String name, Finder finder) in fields) {
|
||||
final InputDecorator decorator = expectBorderlessInput(
|
||||
tester,
|
||||
finder,
|
||||
reason: '$name 평소',
|
||||
);
|
||||
expect(decorator.isFocused, isFalse);
|
||||
expect(decorator.decoration.enabled, isTrue);
|
||||
}
|
||||
|
||||
for (final (String name, Finder finder) in fields) {
|
||||
tester.widget<TextField>(finder).focusNode!.requestFocus();
|
||||
await tester.pumpAndSettle();
|
||||
final InputDecorator decorator = expectBorderlessInput(
|
||||
tester,
|
||||
finder,
|
||||
reason: '$name 초점',
|
||||
);
|
||||
expect(decorator.isFocused, isTrue);
|
||||
}
|
||||
|
||||
// 보내는 동안은 칸을 막는다
|
||||
final Completer<void> gate = Completer<void>();
|
||||
c.sendGate = gate;
|
||||
await tester.tap(find.byTooltip(MailComposeTexts.send));
|
||||
await tester.pump();
|
||||
for (final (String name, Finder finder) in fields) {
|
||||
final InputDecorator decorator = expectBorderlessInput(
|
||||
tester,
|
||||
finder,
|
||||
reason: '$name 꺼짐',
|
||||
);
|
||||
expect(decorator.decoration.enabled, isFalse);
|
||||
}
|
||||
gate.complete();
|
||||
await tester.pumpAndSettle();
|
||||
expect(c.sends, hasLength(1));
|
||||
},
|
||||
);
|
||||
|
||||
/// 받는 사람 칸 줄
|
||||
Finder toRow() => find.descendant(
|
||||
of: find.byKey(const ValueKey<RecipientFieldKind>(RecipientFieldKind.to)),
|
||||
matching: find.byType(ComposeFieldRow),
|
||||
);
|
||||
Finder removeChip(String name) =>
|
||||
find.byTooltip(MailComposeTexts.remove(name));
|
||||
double centerY(WidgetTester tester, Finder finder) =>
|
||||
tester.getCenter(finder).dy;
|
||||
|
||||
testWidgets(
|
||||
'칸 줄 — 칸 이름 15 · 칸 이름과 칸 사이 6 · 오른쪽 여백 4 · 안내 글은 보드의 placeholder 색 · 칩 ✕ 는 #4A5363 (보드 App1Compose)',
|
||||
(WidgetTester tester) async {
|
||||
await start(tester);
|
||||
await open(
|
||||
tester,
|
||||
MailRoutes.compose,
|
||||
extra: const ComposePrefill(to: <String>['이도윤 <doyun@x.kr>']),
|
||||
);
|
||||
await tapTooltip(tester, MailComposeTexts.expandCopies);
|
||||
|
||||
for (final String label in <String>[
|
||||
fieldLabelOf(RecipientFieldKind.to),
|
||||
fieldLabelOf(RecipientFieldKind.cc),
|
||||
fieldLabelOf(RecipientFieldKind.bcc),
|
||||
MailComposeTexts.subject,
|
||||
]) {
|
||||
// 보드 14 → 앱의 크기 대응 15 (앱 디자인 README 「쓰기」)
|
||||
expect(
|
||||
tester.widget<Text>(find.text(label)).style?.fontSize,
|
||||
15,
|
||||
reason: label,
|
||||
);
|
||||
}
|
||||
final Rect label = tester.getRect(find.text(MailComposeTexts.subject));
|
||||
final Rect subject = tester.getRect(subjectField);
|
||||
final Rect row = tester.getRect(
|
||||
find.ancestor(
|
||||
of: subjectField,
|
||||
matching: find.byType(ComposeFieldRow),
|
||||
),
|
||||
);
|
||||
expect(subject.left, label.left + 72 + 6);
|
||||
expect(subject.right, row.right - 4);
|
||||
expect(row.height, 56);
|
||||
|
||||
// 안내 글 — 보드의 placeholder #8A94A3(검색 칸 안내 글과 같은 색)
|
||||
final InputDecoration? subjectHint = tester
|
||||
.widget<TextField>(subjectField)
|
||||
.decoration;
|
||||
final InputDecoration? bodyHint = tester
|
||||
.widget<TextField>(bodyField)
|
||||
.decoration;
|
||||
expect(subjectHint?.hintStyle?.color, AppColors.borderInput);
|
||||
expect(bodyHint?.hintStyle?.color, AppColors.borderInput);
|
||||
expect(AppColors.borderInput, const Color(0xFF8A94A3));
|
||||
|
||||
final IconButton remove = tester.widget<IconButton>(
|
||||
find.ancestor(
|
||||
of: removeChip('이도윤'),
|
||||
matching: find.byType(IconButton),
|
||||
),
|
||||
);
|
||||
expect(remove.color, AppColors.textSecondary);
|
||||
expect(AppColors.textSecondary, const Color(0xFF4A5363));
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'받는 사람 칩이 여러 줄이면 칸 이름 · [참조 펼치기]는 첫 칩 줄에 맞춘다 — 한 줄이면 줄의 가운데 그대로',
|
||||
(WidgetTester tester) async {
|
||||
await start(tester);
|
||||
await open(
|
||||
tester,
|
||||
MailRoutes.compose,
|
||||
extra: const ComposePrefill(
|
||||
to: <String>[
|
||||
'이도윤 <doyun@x.kr>',
|
||||
'박준호 <junho@x.kr>',
|
||||
'최서연 <seoyeon@x.kr>',
|
||||
],
|
||||
),
|
||||
);
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
await tester.pumpAndSettle();
|
||||
final Finder label = find.text(fieldLabelOf(RecipientFieldKind.to));
|
||||
final Finder toggle = find.byTooltip(MailComposeTexts.expandCopies);
|
||||
|
||||
// 두 줄 — 셋째 칩은 아래 줄. 칸 이름 · 단추는 첫 칩 줄의 가운데(줄 전체의 가운데가 아니다)
|
||||
final double firstLine = centerY(tester, removeChip('이도윤'));
|
||||
expect(centerY(tester, removeChip('최서연')), greaterThan(firstLine + 40));
|
||||
expect(
|
||||
centerY(tester, label),
|
||||
moreOrLessEquals(firstLine, epsilon: 0.5),
|
||||
);
|
||||
expect(
|
||||
centerY(tester, toggle),
|
||||
moreOrLessEquals(firstLine, epsilon: 0.5),
|
||||
);
|
||||
expect(
|
||||
tester.getRect(toRow()).top + 4 + 24,
|
||||
moreOrLessEquals(firstLine, epsilon: 0.5),
|
||||
);
|
||||
|
||||
// 한 줄 — 칩 · 칸 이름 · 단추가 줄(56)의 가운데
|
||||
await tester.tap(removeChip('최서연'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(removeChip('박준호'));
|
||||
await tester.pumpAndSettle();
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
await tester.pumpAndSettle();
|
||||
final Rect row = tester.getRect(toRow());
|
||||
expect(row.height, 56);
|
||||
for (final Finder finder in <Finder>[
|
||||
label,
|
||||
toggle,
|
||||
removeChip('이도윤'),
|
||||
]) {
|
||||
expect(
|
||||
centerY(tester, finder),
|
||||
moreOrLessEquals(row.center.dy, epsilon: 0.5),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/// 본문 칸의 최소 높이 — 본문 칸을 감싼 ConstrainedBox(가장 가까운 것)
|
||||
Finder bodyBox() => find
|
||||
.ancestor(of: bodyField, matching: find.byType(ConstrainedBox))
|
||||
.first;
|
||||
|
||||
testWidgets('본문 칸 최소 높이 — 새 메일은 서명이 있어도 240, 원본 메일이 있는 답장은 150 (보드)', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
expect(composeBodyMinHeightOf(hasQuote: false), 240);
|
||||
expect(composeBodyMinHeightOf(hasQuote: true), 150);
|
||||
c.signature = <String, Object?>{
|
||||
'bodyHtml': null,
|
||||
'body': '김아라\n경영지원팀',
|
||||
'isForNew': true,
|
||||
'isForReply': true,
|
||||
};
|
||||
h.mail.put(
|
||||
kClientsId,
|
||||
messageJson(
|
||||
'r1',
|
||||
mailboxId: kClientsId,
|
||||
subject: '유지보수 계약',
|
||||
isRead: true,
|
||||
),
|
||||
detail: <String, Object?>{'textBody': '원문 첫 줄'},
|
||||
);
|
||||
await start(tester);
|
||||
await open(tester, MailRoutes.compose);
|
||||
|
||||
expect(find.text('김아라'), findsOneWidget);
|
||||
expect(
|
||||
tester.widget<ConstrainedBox>(bodyBox()).constraints.minHeight,
|
||||
240,
|
||||
);
|
||||
expect(tester.getSize(bodyBox()).height, 240);
|
||||
|
||||
await tapTooltip(tester, MailComposeTexts.close);
|
||||
expect(find.byType(MessageComposePage), findsNothing);
|
||||
await open(tester, MailRoutes.composeReply('r1'));
|
||||
|
||||
expect(find.text('김아라'), findsOneWidget);
|
||||
expect(find.textContaining('이도윤 · '), findsOneWidget);
|
||||
expect(
|
||||
tester.widget<ConstrainedBox>(bodyBox()).constraints.minHeight,
|
||||
150,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('받는 사람', () {
|
||||
testWidgets('쉼표 · [다음]에 칩 — 붙여 넣은 여러 줄도 나누고, 틀린 주소는 빨간 칩, ✕ 로 뺀다', (
|
||||
WidgetTester tester,
|
||||
|
||||
@@ -21,6 +21,7 @@ import 'package:arasystem_app/features/mail/presentation/providers/message_reade
|
||||
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/message_row.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/widgets/reader_bars.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
@@ -246,6 +247,232 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('빈 본문 (첨부만 있는 메일)', () {
|
||||
const int fileCount = 12;
|
||||
|
||||
/// 첨부 [fileCount] 개 — 본문 칸은 [body] 로 덮는다(없으면 비었다)
|
||||
Map<String, Object?> manyFiles([
|
||||
Map<String, Object?> body = const <String, Object?>{},
|
||||
]) => <String, Object?>{
|
||||
'htmlBody': null,
|
||||
'textBody': '',
|
||||
...body,
|
||||
'attachments': <Object?>[
|
||||
for (int n = 1; n <= fileCount; n++)
|
||||
attachmentJson('a$n', name: 'file-$n.pdf', size: 2048),
|
||||
],
|
||||
};
|
||||
|
||||
/// 머리(제목 · 안내 줄 · 첨부)의 스크롤 칸
|
||||
Finder head() => find.descendant(
|
||||
of: find.byType(MessageReaderPage),
|
||||
matching: find.byType(SingleChildScrollView),
|
||||
);
|
||||
|
||||
Rect actionBar(WidgetTester tester) =>
|
||||
tester.getRect(find.byType(ReaderActionBar));
|
||||
|
||||
testWidgets('보이는 것이 없으면 본문 칸 없이 머리가 화면 전체를 한 스크롤로 — 마지막 첨부까지 닿는다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
// Gmail 이 본문 없이 보낸 서식 본문 — 빈 줄뿐이다
|
||||
put(
|
||||
'm1',
|
||||
isRead: true,
|
||||
detail: manyFiles(<String, Object?>{
|
||||
'htmlBody': '<div dir="ltr"><br></div>',
|
||||
}),
|
||||
);
|
||||
await open(tester, 'm1');
|
||||
|
||||
expect(find.byType(MailBodyView), findsNothing);
|
||||
expect(h.webView.controllers, isEmpty);
|
||||
expect(
|
||||
find.text(MailReaderTexts.attachmentsTitle(fileCount)),
|
||||
findsOneWidget,
|
||||
);
|
||||
// 머리가 아래 막대 위까지 다 쓴다(본문이 있으면 절반까지)
|
||||
final Rect scroll = tester.getRect(head());
|
||||
expect(scroll.bottom, moreOrLessEquals(actionBar(tester).top));
|
||||
|
||||
// 마지막 첨부는 처음에는 화면 밖 — 머리를 스크롤해 닿는다
|
||||
final Finder last = find.text('file-$fileCount.pdf');
|
||||
expect(last.hitTestable(), findsNothing);
|
||||
await tester.drag(head(), const Offset(0, -2000));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final Rect row = tester.getRect(last);
|
||||
expect(row.top, greaterThanOrEqualTo(scroll.top));
|
||||
expect(row.bottom, lessThanOrEqualTo(scroll.bottom));
|
||||
// 본문 칸이 있었다면 머리가 닿지 못하는 아래 절반이다
|
||||
expect(
|
||||
row.center.dy,
|
||||
greaterThan(scroll.top + scroll.height * kReaderHeadMaxFraction),
|
||||
);
|
||||
// 닿은 첨부를 받아 연다
|
||||
h.mail.attachmentFiles['a$fileCount'] = () =>
|
||||
FakeResponse.bytes(<int>[1, 2], contentType: 'application/pdf');
|
||||
await tapTooltip(tester, MailReaderTexts.download('file-$fileCount.pdf'));
|
||||
expect(h.opener.opened.single.path, endsWith('/file-$fileCount.pdf'));
|
||||
});
|
||||
|
||||
testWidgets('본문이 있으면 지금처럼 — 머리는 남은 자리의 절반까지(그 안에서 스크롤), 나머지는 본문 칸', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
put(
|
||||
'm1',
|
||||
isRead: true,
|
||||
detail: manyFiles(<String, Object?>{'textBody': '본문'}),
|
||||
);
|
||||
await open(tester, 'm1');
|
||||
|
||||
expect(find.byType(MailBodyView), findsOneWidget);
|
||||
final Rect scroll = tester.getRect(head());
|
||||
final Rect body = tester.getRect(find.byType(MailBodyView));
|
||||
final Rect bar = actionBar(tester);
|
||||
expect(body.top, moreOrLessEquals(scroll.bottom));
|
||||
expect(body.bottom, moreOrLessEquals(bar.top));
|
||||
expect(
|
||||
scroll.height,
|
||||
moreOrLessEquals((bar.top - scroll.top) * kReaderHeadMaxFraction),
|
||||
);
|
||||
|
||||
// 마지막 첨부도 머리 안에서 스크롤해 닿는다 — 위 절반 안이다
|
||||
await tester.drag(head(), const Offset(0, -2000));
|
||||
await tester.pumpAndSettle();
|
||||
final Rect row = tester.getRect(find.text('file-$fileCount.pdf'));
|
||||
expect(row.bottom, lessThanOrEqualTo(scroll.bottom));
|
||||
expect(find.byType(MailBodyView), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('본문을 만드는 동안은 지금처럼 본문 자리에 진행 줄 — 다 만들어 비었으면 본문 칸이 없어진다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
put('m1', isRead: true, detail: manyFiles());
|
||||
final Completer<SanitizedMailBody> building =
|
||||
Completer<SanitizedMailBody>();
|
||||
h.overrides.add(
|
||||
mailBodyBuilderProvider.overrideWithValue(
|
||||
({
|
||||
required String? htmlBody,
|
||||
required String? textBody,
|
||||
required bool imagesShown,
|
||||
required List<SearchRange> textHighlights,
|
||||
}) => building.future,
|
||||
),
|
||||
);
|
||||
await h.pump(tester);
|
||||
unawaited(h.router.push<void>(MailRoutes.message('m1')));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
|
||||
expect(
|
||||
find.bySemanticsLabel(MailReaderTexts.bodyLoading),
|
||||
findsOneWidget,
|
||||
);
|
||||
final Rect loading = tester.getRect(head());
|
||||
expect(
|
||||
loading.height,
|
||||
moreOrLessEquals(
|
||||
(actionBar(tester).top - loading.top) * kReaderHeadMaxFraction,
|
||||
),
|
||||
);
|
||||
|
||||
building.complete(
|
||||
mailBodyFragment(htmlBody: null, textBody: '', imagesShown: false),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.bySemanticsLabel(MailReaderTexts.bodyLoading), findsNothing);
|
||||
expect(find.byType(MailBodyView), findsNothing);
|
||||
expect(
|
||||
tester.getRect(head()).bottom,
|
||||
moreOrLessEquals(actionBar(tester).top),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('빈 본문에서 [이미지 보기] — 그림이 생기면 본문 칸이 생기고 펼친 받는 사람은 그대로 남는다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
// 서버가 막은 바깥 그림을 지워 공백만 남은 서식 본문 — 글자 본문(빈 글)을 보인다
|
||||
put(
|
||||
'm1',
|
||||
isRead: true,
|
||||
to: <Map<String, Object?>>[addressJson('김아라', me)],
|
||||
detail: <String, Object?>{
|
||||
'htmlBody': '\n',
|
||||
'textBody': '',
|
||||
'hasBlockedImages': true,
|
||||
},
|
||||
);
|
||||
h.mail.shownImageDetails['m1'] = <String, Object?>{
|
||||
'htmlBody': '<img src="/api/mail-images/t1">',
|
||||
'hasBlockedImages': false,
|
||||
'imagesShown': true,
|
||||
};
|
||||
h.mail.mailImages['t1'] = FakeResponse.bytes(
|
||||
kTinyPng,
|
||||
contentType: 'image/png',
|
||||
);
|
||||
await open(tester, 'm1');
|
||||
|
||||
expect(find.byType(MailBodyView), findsNothing);
|
||||
expect(findCopy(MailReaderTexts.imagesBlocked), findsOneWidget);
|
||||
await tester.tap(
|
||||
find.text(MailReaderTexts.recipients(MailReaderTexts.me, 0)),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text(MailReaderTexts.fromLabel), findsOneWidget);
|
||||
|
||||
await tester.ensureVisible(find.text(MailReaderTexts.showImages));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text(MailReaderTexts.showImages));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(MailBodyView), findsOneWidget);
|
||||
expect(lastDocument(), contains('data:image/png;base64,'));
|
||||
expect(findCopy(MailReaderTexts.imagesBlocked), findsNothing);
|
||||
// 머리는 같은 자리의 같은 위젯이라 펼친 칸이 남는다
|
||||
expect(find.text(MailReaderTexts.fromLabel), findsOneWidget);
|
||||
expect(
|
||||
tester.getRect(head()).bottom,
|
||||
moreOrLessEquals(tester.getRect(find.byType(MailBodyView)).top),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('아래 막대가 없는 예약 메일 — 머리 끝에 시스템 내비게이션 막대 몫을 더해 마지막 첨부가 그 위에 온다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
put(
|
||||
's1',
|
||||
mailboxId: kSentId,
|
||||
isRead: true,
|
||||
pendingSend: pendingJson(
|
||||
kTestNow.add(const Duration(days: 1)),
|
||||
kind: 'scheduled',
|
||||
),
|
||||
detail: manyFiles(),
|
||||
);
|
||||
// 3버튼 내비게이션 막대(48) — 물리 픽셀(시험대는 3배)
|
||||
tester.view.padding = const FakeViewPadding(bottom: 48 * 3);
|
||||
addTearDown(tester.view.resetPadding);
|
||||
await open(tester, 's1', location: MailRoutes.mailbox(kSentId));
|
||||
|
||||
expect(find.byType(ReaderActionBar), findsNothing);
|
||||
expect(find.byType(MailBodyView), findsNothing);
|
||||
// 머리가 화면 아래 끝까지 닿는다 — 그 밑의 48 은 내비게이션 막대 자리
|
||||
expect(tester.getRect(head()).bottom, kPhoneSize.height);
|
||||
await tester.drag(head(), const Offset(0, -3000));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// 마지막 첨부 줄 전체가 내비게이션 막대 위에 온다
|
||||
final Rect row = tester.getRect(
|
||||
find.byKey(const ValueKey<String>('attachment-a$fileCount')),
|
||||
);
|
||||
expect(row.bottom, lessThanOrEqualTo(kPhoneSize.height - 48));
|
||||
});
|
||||
});
|
||||
|
||||
group('읽음 처리', () {
|
||||
testWidgets('열면 안 읽은 메일을 읽음으로 — 메일함 수를 먼저 줄이고 목록 줄은 바로 읽음', (
|
||||
WidgetTester tester,
|
||||
|
||||
@@ -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,52 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
/// InputDecorator 가 지금 상태(꺼짐 · 초점 · 평소, 오류)에서 그릴 테두리 — SDK `input_decorator.dart` 가 고르는 차례와 같다
|
||||
/// (상태의 테두리가 없으면 `border`)
|
||||
InputBorder? shownInputBorder(InputDecorator decorator) {
|
||||
final InputDecoration decoration = decorator.decoration;
|
||||
final bool error = decoration.errorText != null || decoration.error != null;
|
||||
final InputBorder? border = !decoration.enabled
|
||||
? (error ? decoration.errorBorder : decoration.disabledBorder)
|
||||
: decorator.isFocused
|
||||
? (error ? decoration.focusedErrorBorder : decoration.focusedBorder)
|
||||
: (error ? decoration.errorBorder : decoration.enabledBorder);
|
||||
return border ?? decoration.border;
|
||||
}
|
||||
|
||||
/// 선이 보이는 테두리인가 — 없음([InputBorder.none]) · 두께 0 은 보이지 않는다
|
||||
bool isDrawnInputBorder(InputBorder? border) =>
|
||||
border != null &&
|
||||
border.borderSide.style != BorderStyle.none &&
|
||||
border.borderSide.width > 0;
|
||||
|
||||
/// [field](TextField 를 찾는 Finder) 의 InputDecorator 가 받은 꾸밈에 보이는 테두리 · 바탕이 없다 — 여섯 테두리와 지금 상태의 테두리를 본다
|
||||
/// (앱 테마의 입력칸 테두리를 물려받지 않았는지). 상태(초점 · 꺼짐)를 더 볼 수 있게 그 InputDecorator 를 돌려준다
|
||||
InputDecorator expectBorderlessInput(
|
||||
WidgetTester tester,
|
||||
Finder field, {
|
||||
String? reason,
|
||||
}) {
|
||||
final InputDecorator decorator = tester.widget<InputDecorator>(
|
||||
find.descendant(of: field, matching: find.byType(InputDecorator)),
|
||||
);
|
||||
final InputDecoration decoration = decorator.decoration;
|
||||
final Map<String, InputBorder?> borders = <String, InputBorder?>{
|
||||
'border': decoration.border,
|
||||
'enabledBorder': decoration.enabledBorder,
|
||||
'focusedBorder': decoration.focusedBorder,
|
||||
'disabledBorder': decoration.disabledBorder,
|
||||
'errorBorder': decoration.errorBorder,
|
||||
'focusedErrorBorder': decoration.focusedErrorBorder,
|
||||
'지금 상태': shownInputBorder(decorator),
|
||||
};
|
||||
borders.forEach((String name, InputBorder? border) {
|
||||
expect(
|
||||
isDrawnInputBorder(border),
|
||||
isFalse,
|
||||
reason: '${reason ?? ''} $name: $border',
|
||||
);
|
||||
});
|
||||
expect(decoration.filled, isFalse, reason: reason);
|
||||
return decorator;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -97,6 +97,7 @@ watch(
|
||||
<template>
|
||||
<ModalDialog
|
||||
id="contact-export-dialog"
|
||||
class="export-dialog"
|
||||
:open="open"
|
||||
:title="CONTACT_EXPORT_TEXT.title"
|
||||
title-id="contact-export-title"
|
||||
@@ -225,6 +226,11 @@ watch(
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 창이 화면보다 길면 창 안이 스크롤한다 — 가져오기 창과 같다(창 바로 아래 칸은 줄어들지 않게 overflow 를 걸지 않는다) */
|
||||
.export-dialog {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.export-dialog__fieldset {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -246,6 +246,7 @@ onBeforeUnmount(stopReading)
|
||||
<template>
|
||||
<ModalDialog
|
||||
id="contact-import-dialog"
|
||||
class="import-dialog"
|
||||
:open="open"
|
||||
:title="CONTACT_IMPORT_TEXT.title"
|
||||
:message="step === 'preview' ? '' : CONTACT_IMPORT_TEXT.message"
|
||||
@@ -371,6 +372,14 @@ onBeforeUnmount(stopReading)
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/*
|
||||
* 창이 화면보다 길면 창 안이 스크롤한다 — 단추 줄은 고정하지 않는다(필터 창과 같다 · 브라우저의 dialog:modal 기본값을 적어 둔다).
|
||||
* 그래서 창 바로 아래 칸은 줄어들지 않아야 한다 — overflow 를 건 칸은 flex-shrink: 0 을 함께 둔다(세 묶음 상자 — ContactImportSummary)
|
||||
*/
|
||||
.import-dialog {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 파일 줄 — 아이콘 · 이름(줄임) · [다른 파일 고르기] (위 글과 붙여 둔다) */
|
||||
.import-dialog__file {
|
||||
display: flex;
|
||||
|
||||
@@ -206,9 +206,15 @@ const problems = computed(() =>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/*
|
||||
* 창(<dialog> — flex 열) 안에서 줄어들지 않는다 — overflow 를 건 칸은 최소 높이가 0 이 되어 창이 넘치는 대신 이 상자가 눌려 잘렸다
|
||||
* (2026-11-10 실사용 시험 — 1366×768 · 1440×900 에서 [이미 있는 연락처는 건너뜁니다] · "그 밖에 N명"이 안 보이고 창도 스크롤하지 않았다).
|
||||
* 창이 화면보다 길면 창 안이 스크롤한다(ContactImportDialog). overflow 는 둥근 모서리 밖으로 펼치기 줄의 바탕이 나오지 않게 둔다
|
||||
*/
|
||||
.import-summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
border: var(--border-width) solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import exportSource from './ContactExportDialog.vue?raw'
|
||||
import importSource from './ContactImportDialog.vue?raw'
|
||||
import groupsSource from './ContactImportGroupFields.vue?raw'
|
||||
import infoSource from './ContactImportInfoField.vue?raw'
|
||||
import pickerSource from './ContactImportPicker.vue?raw'
|
||||
import summarySource from './ContactImportSummary.vue?raw'
|
||||
|
||||
// 가져오기 · 내보내기 창의 높이 (16단계 실사용 시험 2026-11-10 — 1366×768 · 1440×900 에서 미리 보기의 세 묶음 상자가 눌려 잘리고 창도 스크롤하지 않았다)
|
||||
// 창(<dialog>)은 flex 열이다. 화면보다 길면 창 안이 스크롤한다(단추 줄 고정 없음 — 필터 창과 같다). 그런데 overflow 를 건 칸은 flex 열에서
|
||||
// 최소 높이가 0 이 되어 창이 넘치는 대신 그 칸이 눌린다 — 그런 칸은 flex-shrink: 0 을 함께 둔다.
|
||||
// jsdom 은 크기를 재지 않는다 — 원본의 style 을 읽는다 (AppTopBar.spec · MessageReader.stage15.spec 과 같은 읽기)
|
||||
|
||||
/** 컴포넌트 원본에서 한 규칙의 본문 (주석은 뺀다) */
|
||||
function ruleBody(source: string, selector: string): string {
|
||||
const rules = source
|
||||
.slice(source.indexOf('<style'))
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.matchAll(/([^{}]+)\{([^}]*)\}/g)
|
||||
for (const [, found = '', body = ''] of rules) {
|
||||
if (found.replace('<style scoped>', '').trim() === selector) return body
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/** 칸을 잘라 내는 overflow (overflow-wrap 은 아니다) */
|
||||
const CLIPPING_OVERFLOW = /(?:^|[\s;])overflow(?:-[xy])?:\s*(?:hidden|auto|scroll|clip)\b/
|
||||
|
||||
describe('가져오기 · 내보내기 창 — 창 안이 스크롤하고 칸은 줄어들지 않는다', () => {
|
||||
it('창은 overflow-y: auto, 창 바로 아래 칸 가운데 overflow 를 건 칸(세 묶음 상자)은 flex-shrink: 0 · 펼친 목록은 6줄 높이에서 안쪽 스크롤', () => {
|
||||
expect(importSource).toMatch(/<ModalDialog\s+id="contact-import-dialog"\s+class="import-dialog"/)
|
||||
expect(exportSource).toMatch(/<ModalDialog\s+id="contact-export-dialog"\s+class="export-dialog"/)
|
||||
expect(ruleBody(importSource, '.import-dialog')).toMatch(/overflow-y:\s*auto;/)
|
||||
expect(ruleBody(exportSource, '.export-dialog')).toMatch(/overflow-y:\s*auto;/)
|
||||
|
||||
const summary = ruleBody(summarySource, '.import-summary')
|
||||
expect(summary).toMatch(/overflow:\s*hidden;/)
|
||||
expect(summary).toMatch(/flex-shrink:\s*0;/)
|
||||
|
||||
// 창 바로 아래 칸 — 파일 고르기 · 미리 보기(파일 줄 · 찾은 수 · 세 묶음 · 그룹 · 메모 칸 · 한도 안내) · 내보내기
|
||||
const children: readonly (readonly [string, string])[] = [
|
||||
[pickerSource, '.import-picker'],
|
||||
[importSource, '.import-dialog__file'],
|
||||
[importSource, '.import-dialog__found'],
|
||||
[summarySource, '.import-summary'],
|
||||
[groupsSource, '.import-groups'],
|
||||
[infoSource, '.import-info'],
|
||||
[importSource, '.import-dialog__limit'],
|
||||
[exportSource, '.export-dialog__fieldset'],
|
||||
[exportSource, '.export-dialog__file'],
|
||||
]
|
||||
for (const [source, selector] of children) {
|
||||
const body = ruleBody(source, selector)
|
||||
expect(body, selector).not.toBe('')
|
||||
if (CLIPPING_OVERFLOW.test(body)) expect(body, selector).toMatch(/flex-shrink:\s*0;/)
|
||||
}
|
||||
|
||||
// 펼친 두 묶음 목록 — 6줄 높이(186)까지, 넘으면 목록 안에서 넘긴다
|
||||
const list = ruleBody(summarySource, '.import-summary__list')
|
||||
expect(list).toMatch(/max-height:\s*11\.625rem;/)
|
||||
expect(list).toMatch(/overflow-y:\s*auto;/)
|
||||
})
|
||||
})
|
||||
@@ -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;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
|
||||
import { afterEach, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { TextSelection } from '@tiptap/pm/state'
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
|
||||
import MailEditor from '@/components/mail/editor/MailEditor.vue'
|
||||
@@ -10,6 +11,7 @@ import { MAIL_BODY_HTML_MAX_LENGTH } from '@/shared/constants/mail-editor'
|
||||
import { editorOf, fileDragEvent, installProseMirrorDom, pasteEvent } from '@/test-utils/prosemirror-dom'
|
||||
import { LONG_DRAFT_TIMEOUT_MS } from '@/test-utils/timeouts'
|
||||
import { buildQuoteHtml, textToParagraphHtml } from '@/shared/utils/mail-quote'
|
||||
import { newMailHtmlWithSignature } from '@/shared/utils/mail-signature'
|
||||
|
||||
type EditorWrapper = VueWrapper<InstanceType<typeof MailEditor>>
|
||||
|
||||
@@ -36,6 +38,7 @@ describe('MailEditor (서식 편집기)', () => {
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = null
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
function mountEditor(props: Record<string, unknown> = {}): EditorWrapper {
|
||||
@@ -353,6 +356,37 @@ describe('MailEditor (서식 편집기)', () => {
|
||||
expect(view.vm.isChanged()).toBe(false)
|
||||
})
|
||||
|
||||
it('한글 조합 중에 저장한 뒤 조합이 끝나 편집기가 서명 앞 빈 줄을 다시 넣어도 바꿈이 아니다 — 저장한 본문과 같다 (16단계)', () => {
|
||||
const view = mountEditor({ initialHtml: newMailHtmlWithSignature('<p>김아라 · 인프라팀</p>') })
|
||||
const editor = editorOf(view.element)
|
||||
/** 맨 위 단계 블록 — 서명 자리는 [서명], 문단은 글 */
|
||||
const blocks = (): string[] => {
|
||||
const out: string[] = []
|
||||
editor.state.doc.forEach((child) => out.push(child.type.name === 'mailSignature' ? '[서명]' : child.textContent))
|
||||
return out
|
||||
}
|
||||
// 서명 앞 빈 줄(맨 위 단계 두 번째 문단)에서 조합한다 — 조합 중에는 빈 줄을 미룬다
|
||||
const blankLine = editor.state.doc.child(0).nodeSize + 1
|
||||
editor.view.dispatch(editor.state.tr.setSelection(TextSelection.create(editor.state.doc, blankLine)))
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
editor.view.dom.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true }))
|
||||
editor.view.dispatch(editor.state.tr.insertText('한').setMeta('composition', 1))
|
||||
expect(blocks().slice(0, 3)).toEqual(['', '한', '[서명]'])
|
||||
|
||||
// 조합하는 동안 자동 저장 — 저장하는 모양에는 빈 줄이 든다
|
||||
const snapshot = view.vm.captureForSave()
|
||||
if (!snapshot) throw new Error('저장할 본문이 없다')
|
||||
view.vm.markSaved(snapshot.savePoint)
|
||||
expect(view.vm.isChanged()).toBe(false)
|
||||
|
||||
editor.view.dom.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true, data: '한' }))
|
||||
vi.runOnlyPendingTimers()
|
||||
|
||||
expect(blocks().slice(0, 4)).toEqual(['', '한', '', '[서명]'])
|
||||
expect(view.vm.getContent()).toEqual(snapshot.content)
|
||||
expect(view.vm.isChanged()).toBe(false)
|
||||
})
|
||||
|
||||
it('서명 그림은 본문에 남고, 글자 없이 그림만 있어도 서식 본문으로 보낸다 (텍스트 대체 본문은 빈 글)', () => {
|
||||
const view = mountEditor({ initialHtml: `<p>${SIGNATURE_IMAGE}</p>` })
|
||||
|
||||
|
||||
@@ -87,7 +87,8 @@ let mounted = false
|
||||
* 편집기를 다 연 때의 문서(답장 · 전달 초안은 맞춘 뒤) — 쓰기 화면이 "본문을 바꿨는가"를 이것과 비교한다 (나가기 전 확인).
|
||||
* settled: 같은 문서에 편집기가 스스로 끝 빈 줄을 붙인 모양 — 끝이 인용처럼 문단이 아니면 첫 거래(초점 옮기기 등) 때
|
||||
* 붙인다(StarterKit 의 TrailingNode). 사용자가 쓴 것이 아니므로 "바꿈"이 아니다.
|
||||
* 임시 저장이 끝나면 저장한 문서로 바꾼다 (markSaved — 이미 거래를 거친 문서라 settled 도 같다).
|
||||
* 임시 저장이 끝나면 저장한 문서로 바꾼다 (markSaved — 이미 거래를 거친 문서라 끝 빈 줄은 붙어 있다. 다만 한글 조합 중에 저장했으면
|
||||
* 서명 자리 앞 빈 줄이 빠져 있을 수 있다 — 조합이 끝나면 편집기가 스스로 넣으므로 settled 에 넣어 둔다, mail-signature-block.ts).
|
||||
*/
|
||||
let opened: { doc: ProseMirrorNode; settled: ProseMirrorNode } | null = null
|
||||
|
||||
@@ -243,7 +244,7 @@ function captureForSave(): MailEditorSnapshot | null {
|
||||
/** 임시 저장이 끝났다 — 그때의 문서를 기준으로 삼는다 */
|
||||
function markSaved(savePoint: EditorSavePoint) {
|
||||
const doc = savePoint as unknown as ProseMirrorNode
|
||||
opened = { doc, settled: doc }
|
||||
opened = { doc, settled: withSignatureBoundary(doc) }
|
||||
}
|
||||
|
||||
/** 본문 안 서명 자리의 비교 값 — 없으면 null (16단계 [서명 ▾], mail-signature-block.ts) */
|
||||
@@ -366,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;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Editor, createDocument, getHTMLFromFragment, getSchema } from '@tiptap/core'
|
||||
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
|
||||
import { EditorState, TextSelection } from '@tiptap/pm/state'
|
||||
import { afterEach, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { createMailEditorExtensions } from '@/components/mail/editor/mail-extensions'
|
||||
import { docToText, normalizeIncomingHtml, toMailHtml } from '@/components/mail/editor/mail-serialize'
|
||||
@@ -178,7 +178,8 @@ describe('서명 자리 지키기 — 하나뿐 · 앞에는 늘 빈 줄 (2026-1
|
||||
beforeAll(installProseMirrorDom)
|
||||
|
||||
afterEach(() => {
|
||||
for (const editor of editors.splice(0)) editor.destroy()
|
||||
for (const editor of editors.splice(0)) if (!editor.isDestroyed) editor.destroy()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
/** 쓰기 화면의 편집기와 같게 — 플러그인(서명 자리 지키기)은 화면에 붙일 때 켜진다 */
|
||||
@@ -196,6 +197,20 @@ describe('서명 자리 지키기 — 하나뿐 · 앞에는 늘 빈 줄 (2026-1
|
||||
editor.view.dispatch(editor.state.tr.setSelection(TextSelection.create(editor.state.doc, pos + 1)))
|
||||
}
|
||||
|
||||
/**
|
||||
* 한글 조합 — 브라우저처럼 편집 영역에 조합 시작을 알리고, 조합한 글은 조합 표시를 단 거래로 넣는다
|
||||
* (ProseMirror 가 조합 중에 DOM 에서 읽어 보내는 거래와 같은 표시)
|
||||
*/
|
||||
function compose(editor: Editor, text: string, id: number) {
|
||||
editor.view.dom.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true }))
|
||||
editor.view.dispatch(editor.state.tr.insertText(text).setMeta('composition', id))
|
||||
}
|
||||
|
||||
/** 조합을 마친다 — 화살표 · 다른 칸 누르기 등. 문서는 바뀌지 않는다(조합한 글은 이미 들어갔다) */
|
||||
function endComposition(editor: Editor, data: string) {
|
||||
editor.view.dom.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true, data }))
|
||||
}
|
||||
|
||||
/** 저장하고 다시 연 모양 — 보내는 모양(빈 줄을 지킨 문서 → toMailHtml)에서 서버 정화처럼 data-* 를 지우고 다시 읽는다 */
|
||||
function reopened(editor: Editor): ProseMirrorNode {
|
||||
const html = toMailHtml(getHTMLFromFragment(withSignatureBoundary(editor.state.doc).content, schema))
|
||||
@@ -241,7 +256,7 @@ describe('서명 자리 지키기 — 하나뿐 · 앞에는 늘 빈 줄 (2026-1
|
||||
expect(blocks(reopened(editor)).slice(0, 3)).toEqual(['안녕하세요.', '', SIGNED])
|
||||
})
|
||||
|
||||
it('한글을 조합하는 중에는 미룬다 — 보내고 저장하는 모양(withSignatureBoundary)이 채우고, 조합이 끝난 다음 입력에서 편집기도 채운다', () => {
|
||||
it('한글을 조합하는 중에는 미룬다 — 보내고 저장하는 모양(withSignatureBoundary)이 채우고, 다음 입력에서 편집기도 채운다', () => {
|
||||
const editor = mountedEditor(WRITTEN)
|
||||
cursorInto(editor, 1)
|
||||
editor.view.dispatch(editor.state.tr.insertText('감').setMeta('composition', 1))
|
||||
@@ -254,6 +269,78 @@ describe('서명 자리 지키기 — 하나뿐 · 앞에는 늘 빈 줄 (2026-1
|
||||
expect(blocks(editor.state.doc).slice(0, 4)).toEqual(['안녕하세요.', '감 ', '', SIGNED])
|
||||
})
|
||||
|
||||
describe('한글 조합이 끝나면 (2026-11-10 실사용 시험 — 띄어쓰기를 쳐야 빈 줄이 돌아왔다)', () => {
|
||||
it('다른 입력 없이도 빈 줄을 다시 둔다 — ProseMirror 가 조합을 마친 다음 차례에, 커서는 쓰던 자리 · 실행 취소는 조합한 글과 함께', () => {
|
||||
const editor = mountedEditor(WRITTEN)
|
||||
cursorInto(editor, 1)
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
|
||||
compose(editor, '한', 1)
|
||||
expect(editor.view.composing).toBe(true)
|
||||
endComposition(editor, '한')
|
||||
// 조합을 마친 그 순간에는 아직 — ProseMirror 가 조합을 마무리한 다음 차례에 넣는다
|
||||
expect(editor.view.composing).toBe(false)
|
||||
expect(blocks(editor.state.doc).slice(0, 3)).toEqual(['안녕하세요.', '한', SIGNED])
|
||||
|
||||
vi.runOnlyPendingTimers()
|
||||
|
||||
expect(blocks(editor.state.doc).slice(0, 4)).toEqual(['안녕하세요.', '한', '', SIGNED])
|
||||
expect(editor.state.selection.$from.parent.textContent).toBe('한')
|
||||
expect(editor.state.selection.$from.parentOffset).toBe(1)
|
||||
expect(blocks(reopened(editor)).slice(0, 4)).toEqual(['안녕하세요.', '한', '', SIGNED])
|
||||
|
||||
editor.commands.undo()
|
||||
expect(blocks(editor.state.doc).slice(0, 3)).toEqual(['안녕하세요.', '', SIGNED])
|
||||
})
|
||||
|
||||
it('다음 조합이 곧바로 이어지면(이어 쓰는 한글) 조합 중에는 건드리지 않고, 그 조합이 끝난 뒤에 넣는다', () => {
|
||||
const editor = mountedEditor(WRITTEN)
|
||||
cursorInto(editor, 1)
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
|
||||
compose(editor, '한', 1)
|
||||
endComposition(editor, '한')
|
||||
compose(editor, '국', 2)
|
||||
vi.runOnlyPendingTimers()
|
||||
expect(blocks(editor.state.doc).slice(0, 3)).toEqual(['안녕하세요.', '한국', SIGNED])
|
||||
|
||||
endComposition(editor, '국')
|
||||
vi.runOnlyPendingTimers()
|
||||
expect(blocks(editor.state.doc).slice(0, 4)).toEqual(['안녕하세요.', '한국', '', SIGNED])
|
||||
expect(editor.state.selection.$from.parentOffset).toBe(2)
|
||||
})
|
||||
|
||||
it('빈 줄이 빠지지 않았으면 아무것도 하지 않는다 — 쓸 줄에서 조합해도 거래를 보내지 않는다', () => {
|
||||
const editor = mountedEditor(WRITTEN)
|
||||
cursorInto(editor, 0)
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
|
||||
compose(editor, '네', 1)
|
||||
endComposition(editor, '네')
|
||||
const before = editor.state
|
||||
vi.runOnlyPendingTimers()
|
||||
|
||||
expect(editor.state).toBe(before)
|
||||
expect(blocks(editor.state.doc).slice(0, 3)).toEqual(['네안녕하세요.', '', SIGNED])
|
||||
})
|
||||
|
||||
it('편집기를 내리면 조합이 끝나도 아무것도 하지 않는다 — 기다리던 차례와 듣던 것을 거둔다', () => {
|
||||
const editor = mountedEditor(WRITTEN)
|
||||
cursorInto(editor, 1)
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
const dom = editor.view.dom
|
||||
|
||||
compose(editor, '한', 1)
|
||||
endComposition(editor, '한')
|
||||
editor.destroy()
|
||||
expect(() => vi.runOnlyPendingTimers()).not.toThrow()
|
||||
|
||||
const pending = vi.getTimerCount()
|
||||
dom.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true, data: '한' }))
|
||||
expect(vi.getTimerCount()).toBe(pending)
|
||||
})
|
||||
})
|
||||
|
||||
it('빈 줄이 있으면 그대로 — 앱이 만든 저장본 모양도 바꾸지 않는다', () => {
|
||||
const app = doc(
|
||||
`<div style="${WRAPPER_STYLE}">` +
|
||||
|
||||
@@ -5,14 +5,16 @@
|
||||
// 저장본 · 보내기 취소로 되돌린 메일은 "맨 위 단계에서 빈 문단 바로 뒤의 속성 없는 div 이고 글이나 그림이 있다"(서버 정화가 data-* 를 지운다).
|
||||
// 맨 위 단계 = 문서 바로 아래 · 또는 문서에 하나뿐인 감싸는 div(보낸 본문의 글꼴 div) 바로 아래. 원문 인용 안의 div 는 서명 자리가 아니다.
|
||||
// - 서명 자리는 하나다 — 붙여 넣기 · 끌어 놓기로 하나 더 생기면 처음부터 있던 것만 두고 나머지는 문단으로 푼다(appendTransaction).
|
||||
// - 서명 자리 바로 앞에는 늘 빈 줄 — 그 빈 줄에 글을 쓰거나 Backspace 로 지우면 빈 줄을 다시 넣는다(appendTransaction — 한글 조합 중에는 미룬다),
|
||||
// 보내고 저장하는 모양도 한 번 더 지킨다(withSignatureBoundary — MailEditor 의 contentOf). 빈 줄이 빠지면 다시 열 때 경계를 찾지 못한다(2026-11-10 리뷰 M3).
|
||||
// - 서명 자리 바로 앞에는 늘 빈 줄 — 그 빈 줄에 글을 쓰거나 Backspace 로 지우면 빈 줄을 다시 넣는다(appendTransaction — 한글 조합 중에는 미루고
|
||||
// 조합이 끝나면 다른 입력 없이 채운다), 보내고 저장하는 모양도 한 번 더 지킨다(withSignatureBoundary — MailEditor 의 contentOf).
|
||||
// 빈 줄이 빠지면 다시 열 때 경계를 찾지 못한다(2026-11-10 리뷰 M3).
|
||||
// - 바꾸기(replaceSignatureTransaction): 그 자리만 새 서명으로 — 쓴 글 · 인용 · 첨부는 그대로이고 실행 취소로 되돌린다.
|
||||
// 넣지 않음은 서명 자리와 그 앞의 빈 줄을 뺀다. 서명 자리가 없으면 처음 넣는 자리(새 메일은 본문 끝, 답장 · 전달은 원문 인용 위)에 빈 줄 하나 뒤로 넣는다.
|
||||
// - 어느 서명이 들었는지(signatureFingerprintOf)는 글(공백을 하나로 줄여)과 그림 주소로 견준다 — 고쳤으면 어느 서명과도 같지 않다.
|
||||
import { Node, createDocument } from '@tiptap/core'
|
||||
import { Fragment, type Node as ProseMirrorNode, type Schema } from '@tiptap/pm/model'
|
||||
import { Plugin, PluginKey, type EditorState, type Transaction } from '@tiptap/pm/state'
|
||||
import { Plugin, PluginKey, type EditorState, type PluginView, type Transaction } from '@tiptap/pm/state'
|
||||
import type { EditorView } from '@tiptap/pm/view'
|
||||
|
||||
import { blockLines, normalizeIncomingHtml } from '@/components/mail/editor/mail-serialize'
|
||||
import { SIGNATURE_IMAGE_NODE } from '@/components/mail/editor/signature-image'
|
||||
@@ -191,6 +193,15 @@ export function withSignatureBoundary(doc: ProseMirrorNode): ProseMirrorNode {
|
||||
|
||||
const signatureBlockKey = new PluginKey('mailSignatureBlock')
|
||||
|
||||
/** 조합이 끝났다 — 문서를 바꾸지 않는 거래에 이 표시를 달아 서명 자리 지키기(appendTransaction)를 깨운다 */
|
||||
const COMPOSITION_ENDED = 'compositionEnded'
|
||||
|
||||
/**
|
||||
* 한글 조합이 끝난 뒤 미뤄 둔 빈 줄을 채우는 때 — ProseMirror 는 compositionend 뒤 20ms 에 조합을 마무리한다(endComposition).
|
||||
* 그 다음 차례에 본다 (눈으로는 조합을 마친 그때와 같다)
|
||||
*/
|
||||
const COMPOSITION_SETTLE_DELAY_MS = 50
|
||||
|
||||
/**
|
||||
* 서명 자리는 하나 — 붙여 넣기(서명 안에서 복사한 글은 서명 자리째 붙는다) 등으로 둘 이상이 되면 처음부터 있던 것만 두고 나머지는 문단으로 푼다.
|
||||
* 처음부터 있던 것을 모르면(없었거나 지워졌다) 첫 것을 둔다
|
||||
@@ -230,10 +241,12 @@ function unwrapExtraSignatures(
|
||||
* 서명 자리 지키기 — 문서가 바뀐 거래 뒤에 (1) 서명 자리를 하나로 (2) 서명 자리 바로 앞에 빈 줄을 둔다.
|
||||
* (2)는 그 빈 줄에 글을 쓰거나(Backspace · Delete 로) 빈 줄을 지웠을 때 빈 줄을 다시 넣는 것이다 — 쓴 글은 그대로 두고 그 아래 · 서명 위에 새 빈 줄,
|
||||
* 커서는 쓰던 자리 그대로, 실행 취소는 그 입력과 한 번에 되돌린다(덧붙인 거래는 입력과 같은 기록 묶음).
|
||||
* 한글을 조합하는 중(거래의 composition 표시)에는 미룬다 — 조합 중인 글 옆의 화면을 바꾸지 않게. 다음 거래 · 보내고 저장하는 모양(withSignatureBoundary)이 채운다
|
||||
* 한글을 조합하는 중(거래의 composition 표시)에는 미룬다 — 조합 중인 글 옆의 화면을 바꾸지 않게. 조합이 끝나면 compositionSettleView 가
|
||||
* 표시(COMPOSITION_ENDED)만 단 거래로 깨워 채우고, 그 전에 보내거나 저장하는 모양은 withSignatureBoundary 가 채운다
|
||||
*/
|
||||
function guardSignatureBlock(transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) {
|
||||
if (!transactions.some((transaction) => transaction.docChanged)) return null
|
||||
const woken = transactions.some((transaction) => transaction.getMeta(signatureBlockKey) === COMPOSITION_ENDED)
|
||||
if (!woken && !transactions.some((transaction) => transaction.docChanged)) return null
|
||||
const tr = newState.tr
|
||||
unwrapExtraSignatures(transactions, oldState, newState, tr)
|
||||
const composing = transactions.some((transaction) => Boolean(transaction.getMeta('composition')))
|
||||
@@ -243,6 +256,41 @@ function guardSignatureBlock(transactions: readonly Transaction[], oldState: Edi
|
||||
return tr.docChanged ? tr : null
|
||||
}
|
||||
|
||||
/**
|
||||
* 한글 조합이 끝나면 미뤄 둔 빈 줄을 채운다 — 조합을 마칠 때는 대개 거래가 오지 않고(조합한 글은 조합 중에 이미 들어갔다)
|
||||
* 오더라도 조합 표시를 달고 온다. 그래서 다른 입력 · 화살표 · 다른 칸 누르기만으로는 빈 줄이 돌아오지 않았다(2026-11-10 실사용 시험).
|
||||
* compositionend 뒤 ProseMirror 가 조합을 마무리한 다음 차례에 빈 줄이 빠져 있으면 표시만 단 빈 거래로 지키기를 깨운다 —
|
||||
* 빈 줄은 덧붙인 거래라 커서는 쓰던 자리, 실행 취소는 조합한 글과 한 번에 되돌린다.
|
||||
* 그사이 새 조합이 시작됐으면(이어 쓰는 한글) 건드리지 않는다 — 그 조합이 끝날 때 다시 본다
|
||||
*/
|
||||
function compositionSettleView(view: EditorView): PluginView {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function cancel() {
|
||||
if (timer !== null) clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
|
||||
function settle() {
|
||||
timer = null
|
||||
if (view.isDestroyed || view.composing || !blockMissingBlankLine(view.state.doc)) return
|
||||
view.dispatch(view.state.tr.setMeta(signatureBlockKey, COMPOSITION_ENDED))
|
||||
}
|
||||
|
||||
function onCompositionEnd() {
|
||||
cancel()
|
||||
timer = setTimeout(settle, COMPOSITION_SETTLE_DELAY_MS)
|
||||
}
|
||||
|
||||
view.dom.addEventListener('compositionend', onCompositionEnd)
|
||||
return {
|
||||
destroy() {
|
||||
cancel()
|
||||
view.dom.removeEventListener('compositionend', onCompositionEnd)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** 서명 자리 노드 — 쓰기 본문 스키마(mail-extensions.ts)에 든다. 서명 칸(SignatureEditor) 스키마에는 없다 */
|
||||
export const MailSignatureBlock = Node.create({
|
||||
name: MAIL_SIGNATURE_NODE,
|
||||
@@ -264,6 +312,6 @@ export const MailSignatureBlock = Node.create({
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [new Plugin({ key: signatureBlockKey, appendTransaction: guardSignatureBlock })]
|
||||
return [new Plugin({ key: signatureBlockKey, appendTransaction: guardSignatureBlock, view: compositionSettleView })]
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user