From 1962f07d7949b343f3ccb19e140ad6b816226f90 Mon Sep 17 00:00:00 2001 From: ttipo Date: Tue, 10 Nov 2026 16:33:55 +0900 Subject: [PATCH 01/12] =?UTF-8?q?docs:=20=EB=A1=9C=EB=93=9C=EB=A7=B5?= =?UTF-8?q?=EC=97=90=2016=EB=8B=A8=EA=B3=84=20=ED=95=A9=EC=B9=A8=EA=B3=BC?= =?UTF-8?q?=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20=EB=82=B4=EC=97=AD=20?= =?UTF-8?q?=EC=8B=9C=EC=9E=91=EC=9D=84=20=EC=A0=81=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/roadmap.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 8ba300f..86e86a3 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -8,9 +8,9 @@ ## 지금 - **운영**: 메일 1~13단계. 2026-11-05 옛 메일 서버(mailcow)에서 전 직원이 옮겨 왔다(메일만 옮겼다 — 주소록 · 달력 · 필터 · 부재중 · 서명은 직원이 새로). -- **배포를 기다림**: 메일 14 · 15단계 · 앱 서버(앱 로그인 · 새 메일 알림) · 휴대폰 앱 받기 — main 에 합쳤고, 전환 뒤 안정화 1~2주를 지나 11월 셋째 주에 운영에 올린다. -- **개발 중**: 메일 16단계(개인 설정). -- **다음**: 16단계가 끝날 즈음 아래 "추후 후보" 가운데 직원 요청이 실제로 있는 것을 고른다. 그다음은 공통 기반(조직 · 권한). +- **배포를 기다림**: 메일 14 · 15 · 16단계 · 앱 서버(앱 로그인 · 새 메일 알림) · 휴대폰 앱 받기 — main 에 합쳤고, 전환 뒤 안정화 1~2주를 지나 11월 셋째 주에 운영에 올린다. +- **개발 중**: 업데이트 내역 — 웹메일 · 휴대폰 앱의 설정에서 무엇이 새로 생기거나 바뀌었는지 읽는 게시판(글은 저장소의 글 파일, 셋째 주 배포에 함께). +- **다음**: 아래 "추후 후보" 가운데 직원 요청이 실제로 있는 것을 고른다. 그다음은 공통 기반(조직 · 권한). ## 1부 — 메일 @@ -31,9 +31,9 @@ | 13 | 전환 준비 — 새 비밀번호 · 2단계 필수 · 메일 프로그램 연결 안내 · 운영 도구 · 백업 · 감시 · 휴지통 · 스팸 30일 | 운영 | | 14 | 자체 검색 색인 · 조건 검색 · 검색어 강조 · 같은 대화 · 대화로 묶어 보기 | main 에 합침 — 배포 기다림 | | 15 | 메일 · 메일함 다루기 — 읽기 화면 [이동] · 끌어다 놓기 · 메일함 옮기기 · 하위까지 지우기 · 차단할 때 받은편지함 정리 · 예약 메일 [지금 보내기] · 같은 대화 [모두 펼치기] | main 에 합침 — 배포 기다림 | -| 16 | 개인 설정 — **주소록 가져오기 · 내보내기**(vCard · CSV · 엑셀) · 여러 서명(사내 · 대외 · 영문) · 부재중 응답의 서식 본문과 사내/외부 구분 · 2단계 인증 휴대폰 직접 바꾸기 | 개발 중 | +| 16 | 개인 설정 — **주소록 가져오기 · 내보내기**(vCard · CSV · 엑셀) · 여러 서명(사내 · 대외 · 영문) · 부재중 응답의 서식 본문과 사내/외부 구분 · 2단계 인증 휴대폰 직접 바꾸기 | main 에 합침 — 배포 기다림 | -- 15단계부터는 직원이 쓰며 바라는 것을 먼저 본다 — 16단계가 끝날 즈음 아래 "추후 후보" 가운데 요청이 실제로 있는 것을 골라 다음 단계로 넣는다. +- 15단계부터는 직원이 쓰며 바라는 것을 먼저 본다 — 아래 "추후 후보" 가운데 요청이 실제로 있는 것을 골라 다음 단계로 넣는다. - 2026-11-09 계획에서 뺀 것: 수신 확인 · 사내 발송 취소(사내 받는 사람에게만 되고, 흔한 실수는 보내기 취소가 막는다 — 아래 후보로). ## 공통 기반 — 조직 · 권한 -- 2.52.0 From abe4c1364c0eda4b340677a4b7e0f63020fc696a Mon Sep 17 00:00:00 2001 From: ttipo Date: Tue, 10 Nov 2026 16:43:26 +0900 Subject: [PATCH 02/12] =?UTF-8?q?docs:=20=EC=97=85=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=20=EB=82=B4=EC=97=AD=20=EB=B3=B4=EB=93=9C=EB=A5=BC=20?= =?UTF-8?q?=EB=8D=94=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 웹 설정 › 정보 › 업데이트 내역과 앱 설정 › 앱 정보 › 업데이트 내역(게시판 — 목록에서 글로), 글 파일 쓰는 법을 한 보드에 그렸다(캔버스 v80 확정). Co-Authored-By: Claude Opus 5.5 --- docs/design/webmail/ReleaseNotes.dc.html | 1308 ++++++++++++++++++++++ docs/design/webmail/canvas.json | 8 +- 2 files changed, 1313 insertions(+), 3 deletions(-) create mode 100644 docs/design/webmail/ReleaseNotes.dc.html diff --git a/docs/design/webmail/ReleaseNotes.dc.html b/docs/design/webmail/ReleaseNotes.dc.html new file mode 100644 index 0000000..07534fc --- /dev/null +++ b/docs/design/webmail/ReleaseNotes.dc.html @@ -0,0 +1,1308 @@ + + + + +설정 · 업데이트 내역 (웹 · 휴대폰 앱) · 목록과 글 + + + + + + + +
+ +
+

설정 · 업데이트 내역 (웹 · 휴대폰 앱) · 목록과 글

+

직원이 ARA SYSTEM에 무엇이 새로 생기거나 바뀌었는지 읽는 화면입니다. 업데이트할 때마다 글을 하나씩 더하는 게시판 꼴이라, 목록에서 글을 골라 들어가 읽고 [목록]이나 뒤로로 돌아옵니다. 글은 운영자가 저장소의 backend/release-notes 폴더에 글마다 파일 하나로 쓰고, 웹과 앱이 같은 글을 보여 줍니다(올리는 화면은 없습니다). A는 웹(목록, 글 화면, 좁은 폭), B는 안드로이드 앱, C는 글 파일을 쓰는 법입니다. 맨 위 글은 다음 배포에 낼 첫 글의 초안이고 날짜는 예시입니다. 나머지 세 글은 목록 모양을 보이려고 넣은 예시입니다. 초안이며 사용자 확인 전입니다(2026-11-10).

+
+ +
+
+

A1. 웹 · 목록 (PC 1440)

+

설정 왼쪽 메뉴에 셋째 묶음 정보를 새로 두고 그 아래에 업데이트 내역을 둡니다. 주소는 /settings/info/release-notes입니다. 줄을 누르면 A2 글 화면입니다.

+
+
+
+ + + +
+
+

{{ listStateTitle }}

+

{{ listStateNote }}

+
+
+
+ +
+ + + +
+ +
+ 설정 +
+ + 통합 검색 +
+
+ +
+ 김아라 + ara.kim@arasystem.it.kr +
+ + + 로그아웃 + +
+
+ +
+ + + +
+
+
+

업데이트 내역

+

웹메일과 휴대폰 앱에 새로 생기거나 바뀐 것을 알려 드립니다.

+
+ +
+
+ + +
+
+
+
+ +
+ + +
+ + +
+
+
+
+ +
+ +

문제가 생겨 하던 일을 마치지 못했습니다. 잠시 뒤 다시 시도해 주세요.

+ + 문의 코드 3f1c0a2e +
+
+ +
+ +

아직 알려 드릴 업데이트가 없습니다.

+
+
+
+
+
+
+
+ +
+
+

A2. 웹 · 글 화면 (PC 1440)

+

주소는 /settings/info/release-notes/{id}이고 id는 글 파일 이름입니다(예 2026-11-18). 글을 보는 동안에도 메뉴의 업데이트 내역이 고른 상태입니다.

+
+
+
+ + + +
+
+

{{ postStateTitle }}

+

{{ postStateNote }}

+
+
+
+ +
+ + + +
+ +
+ 설정 +
+ + 통합 검색 +
+
+ +
+ 김아라 + ara.kim@arasystem.it.kr +
+ + + 로그아웃 + +
+
+ +
+ + + +
+
+ +
+
+ +
+
+
+

{{ top.date }}

+

{{ top.title }}

+
+
+ +
+

{{ g.head }}

+
    + +
  • {{ l.text }}
  • +
    +
+
+
+
+
+ + +
+
+ +
+
+
+

{{ mid.date }}

+

{{ mid.title }}

+
+
+ +
+

{{ g.head }}

+
    + +
  • {{ l.text }}
  • +
    +
+
+
+
+
+ + +
+
+ +
+ +

찾을 수 없습니다. 이미 지워졌거나 옮겨졌을 수 있습니다.

+ + 문의 코드 3f1c0a2e +
+
+ +
+ +

문제가 생겨 하던 일을 마치지 못했습니다. 잠시 뒤 다시 시도해 주세요.

+ + 문의 코드 3f1c0a2e +
+
+
+
+
+
+
+ +
+
+

A3. 웹 · 좁은 폭 (휴대폰 브라우저 360)

+

지금 설정 화면이 좁은 폭에서 바뀌는 그대로이고, 목록 줄만 날짜를 제목 아래로 내립니다. 두 장 모두 메뉴가 끝나는 곳까지 내린 모습입니다.

+
+ +
+ +
+
+

목록

+

머리 줄을 감추고 날짜를 제목 아래로 내립니다. 줄 전체를 누르는 것은 같습니다.

+
+
+ +
+ +
+ +
+
+
+

업데이트 내역

+

웹메일과 휴대폰 앱에 새로 생기거나 바뀐 것을 알려 드립니다.

+
+
+ +
+
+
+
+
+
+
+
+ +
+
+

글 화면

+

[← 목록으로] 줄, 날짜, 제목, 묶음 차례와 글자 크기는 넓은 화면과 같고, 글은 한 칸으로 흐릅니다.

+
+
+ +
+ +
+ +
+
+
+ 목록으로 +
+
+
+
+

{{ top.date }}

+

{{ top.title }}

+
+
+ +
+

{{ g.head }}

+
    + +
  • {{ l.text }}
  • +
    +
+
+
+
+
+
+
+
+
+
+ +
+
+

좁은 폭에서 바뀌는 것

+
    +
  • 화면 폭이 840보다 좁으면(레일 72, 메뉴 208, 글 칸 560이 한 줄에 들지 않으면) 메뉴가 위로 올라가 폭 전체를 쓰고, 목록과 글은 그 아래로 내려갑니다. 지금 다른 설정 화면과 같습니다.
  • +
  • 목록 칸이 480보다 좁으면 머리 줄을 감추고 날짜를 제목 아래로 내립니다. 이것만 새로 정한 것입니다.
  • +
  • 목록에서 글을 누르면 새 주소의 화면이 열리니, 휴대폰 브라우저에서는 글을 볼 때도 메뉴 아래로 내려야 합니다(지금 설정 화면들과 같아 그대로).
  • +
  • 폭 360에서는 상단 바가 세 줄(설정, 검색 칸, 계정)이 되고, 화면 폭 480 이하에서는 메일 주소를 감추고 이름만 둡니다(15단계 그대로).
  • +
  • 글 칸의 좌우 여백 32도 그대로라 폭 360에서 글 폭은 224입니다.
  • +
+
+
+ +
+
+ +
+
+

B. 휴대폰 앱 (안드로이드 360×800)

+

설정 탭의 앱 정보 절에서 업데이트 내역 목록으로 들어가고, 줄을 누르면 글 화면입니다. 둘 다 탭 막대 없는 전체 화면이고 위 막대의 뒤로로 돌아갑니다. 글은 웹과 같고, 새 화면의 글자는 앱 테마 크기(13, 15, 16, 18)로 그렸습니다.

+
+ +
+ +
+
+

B1. 설정 탭

+

앱 정보 절의 둘째 줄(앱 버전 다음)이 새 줄이고, 오픈소스 라이선스 줄과 같은 꼴입니다. 누르면 B2 목록입니다. 이 크기에서는 [로그아웃]이 탭 막대에 조금 가리고, 내리면 다 보입니다.

+
+
+
+

설정

+
+
+
+

내 정보

+
+ +
+ 김아라 + ara.kim@arasystem.it.kr +
+
+
+ +
+

알림

+
+
+ 새 메일 알림 + 새 메일이 오면 휴대폰 알림으로 알려 드립니다. +
+ +
+
+ +
+

앱 정보

+
+ 앱 버전 + 1.0.0 +
+ 업데이트 내역 + + +
+ +
+

문의

+

앱이나 계정 문제로 도움이 필요하면 연락해 주세요.
IT 담당 홍길동 · 02-000-0000 · 사내 메신저 @it

+
+
+ +
+
+ +
+
+ +
+
+

B2. 목록

+

위 막대에 뒤로와 제목을 둡니다. 줄마다 제목, 날짜, 끝에 ›이고 줄 전체를 누릅니다(68). [더 보기]는 글이 20개를 넘을 때만 있습니다(보드는 자리를 보이려고 그렸습니다).

+
+
+
+ +

업데이트 내역

+
+
+ +
+ +
+
+
+
+ +
+
+

B3. 글 · 맨 위 글

+

위 막대에는 뒤로만 둡니다. 날짜, 제목, 묶음 차례이고 길면 화면이 스크롤합니다. 끝에는 이전 글 줄만 있습니다(더 새 글이 없어 다음 글 줄은 없습니다).

+
+
+
+ +
+
+
+
+

{{ top.date }}

+

{{ top.title }}

+
+ +
+

{{ g.head }}

+
    + +
  • {{ l.text }}
  • +
    +
+
+
+
+ +
+
+
+ +
+ +
+ +
+
+

B4. 글 · 가운데 글

+

짧은 글이라 끝의 다른 글 줄이 한 화면에 들어옵니다. 다음 글(더 새 글)이 위, 이전 글(더 옛 글)이 아래입니다. 목록과 같은 차례입니다.

+
+
+
+ +
+
+
+
+

{{ mid.date }}

+

{{ mid.title }}

+
+ +
+

{{ g.head }}

+
    + +
  • {{ l.text }}
  • +
    +
+
+
+
+ +
+
+
+ +
+
+

B5. 글 · 없는 글

+

지워졌거나 주소가 틀린 글입니다(404 RES_001). 사전 글, 문의 코드, [목록으로]를 둡니다. 위 막대의 뒤로도 목록입니다.

+
+
+
+ +
+
+ +

찾을 수 없습니다. 이미 지워졌거나 옮겨졌을 수 있습니다.

+

문의 코드 3f1c0a2e

+ +
+
+
+ +
+
+

B6. 글 · 받지 못함

+

그 밖의 실패입니다. 사전 글, 문의 코드, [다시 시도]를 둡니다. 메일 목록의 받지 못함과 같은 꼴입니다.

+
+
+
+ +
+
+ +

문제가 생겨 하던 일을 마치지 못했습니다. 잠시 뒤 다시 시도해 주세요.

+

문의 코드 3f1c0a2e

+ +
+
+
+ +
+ +
+ +
+
+

B7. 목록 · 불러오는 중

+

줄 자리를 막대로 잡아 둡니다. 웹과 같은 결입니다.

+
+
+
+ +

업데이트 내역

+
+
+ +
+ + +
+
+
+
+
+ +
+
+

B8. 목록 · 받지 못함

+

사전 글, 문의 코드, [다시 시도]를 둡니다.

+
+
+
+ +

업데이트 내역

+
+
+ +

문제가 생겨 하던 일을 마치지 못했습니다. 잠시 뒤 다시 시도해 주세요.

+

문의 코드 3f1c0a2e

+ +
+
+
+ +
+
+

B9. 목록 · 글이 없을 때

+

빈 메일함과 같은 꼴로 한 줄만 둡니다.

+
+
+
+ +

업데이트 내역

+
+
+ +

아직 알려 드릴 업데이트가 없습니다.

+
+
+
+ +
+
+ +
+
+

C. 쓰는 법 (운영자용)

+

화면이 아니라 설명입니다. 글 하나가 파일 하나입니다. 업데이트할 때마다 저장소의 backend/release-notes 폴더에 파일을 하나 더해 커밋하고 배포하면(백엔드를 다시 빌드) 웹과 앱에 함께 나옵니다. 고치거나 지울 때도 그 파일만 다룹니다. Gitea 웹 화면에서 해도 됩니다.

+
+ +
+ +
+
+

폴더와 파일

+

파일 이름이 날짜이자 글 주소입니다 · 마크다운의 아주 작은 일부만 씁니다

+
+
+
backend/release-notes/
+
├ 2026-11-03.md
+
├ 2026-11-04.md
+
├ 2026-11-05.md
+
└ 2026-11-18.md 새 글은 새 파일 하나
+
+
+
2026-11-18.md
+
+ +
+ + {{ r.sign }}{{ r.text }} +
+
+
+
+
+
2026-11-04.md
+
+ +
+ + {{ r.sign }}{{ r.text }} +
+
+
+
+

⑤의 옅은 청록 칸은 빈칸 두 개 자리입니다(보드에서 보이게 칠했습니다). ⋮ 아래는 보드에서만 줄였습니다.

+
+ +
+
+

화면에 나오는 모양

+

웹 모양 그대로 · 번호는 왼쪽 파일의 줄과 짝입니다

+
+

목록

+
+ +
메일 검색이 빨라지고 휴대폰 앱이 나왔습니다2026년 11월 18일
+
화면의 안내 글을 다듬었습니다2026년 11월 5일
+
왼쪽 메뉴를 정리했습니다2026년 11월 4일
+
+

글 화면 (2026-11-18.md)

+
+
2026년 11월 18일
+
메일 검색이 빨라지고 휴대폰 앱이 나왔습니다
+
+
휴대폰 앱
+
안드로이드 휴대폰 앱이 나왔습니다. 웹메일의 [설정] → [휴대폰 앱]에서 받을 수 있습니다.
+
앱에서 메일을 읽고 쓰고 찾을 수 있고, 새 메일이 오면 휴대폰으로 알려 드립니다. 알림에는 새 메일이 몇 통인지만 나오고, 보낸 사람이나 제목은 나오지 않습니다.
+
+
+
메일 검색
+
메일 검색이 빨라졌습니다. [조건]을 눌러 보낸 사람, 받는 사람, 제목, 본문, 기간, 첨부로 나눠 찾을 수 있습니다.
+
+
+
+

글 화면 (2026-11-04.md)

+
+
2026년 11월 4일
+
왼쪽 메뉴를 정리했습니다
+
아직 준비 중인 [ERP]를 왼쪽 메뉴에서 뺐습니다.
+
+

⑥의 메모 줄은 화면에 나오지 않습니다. ⑤는 앞 줄에 띄어쓰기 하나로 이어 붙은 자리입니다(보드에서 보이게 칠했습니다).

+
+ +
+ +
+ +
+

줄마다 뜻

+
+ +
+
+ + {{ d.code }} +
+
{{ d.text }}
+
+
+
+
+ +
+

지킬 것

+
    +
  • 글 하나가 파일 하나입니다. 업데이트할 때마다 backend/release-notes 폴더에 파일을 하나 더해 커밋하고 배포합니다. 고치거나 지울 때도 그 파일만 다룹니다.
  • +
  • 파일 이름은 날짜입니다(2026-11-18.md). 같은 날 두 번째 글은 2026-11-18-2.md로 씁니다. 날짜는 파일 이름에서 읽으니 파일 안에 다시 쓰지 않습니다.
  • +
  • 파일 이름은 글 주소가 됩니다. 날짜를 고치면 주소도 바뀌어, 그전 주소로 들어오면 없는 글 화면이 나옵니다.
  • +
  • 첫 줄은 # 제목이고 꼭 있어야 합니다. 목록의 한 줄과 글 화면의 제목이 됩니다. 글 하나에 줄이 하나는 있어야 합니다.
  • +
  • 길이는 제목 100자, 묶음 머리 40자, 한 줄 500자까지입니다.
  • +
  • 굵게, 링크, 그림은 쓸 수 없고 쓴 글이 그대로 나옵니다. 단추나 메뉴 이름은 [설정] → [휴대폰 앱]처럼 씁니다.
  • +
  • 형식이 틀린 글은 시험(CI)이 막습니다. 그래도 배포되면 서버는 그 글만 건너뛰고 로그에 파일 이름과 줄 번호를 남깁니다. 서버는 그대로 뜹니다.
  • +
  • 웹과 앱이 같은 글을 보여 줍니다. 앱은 옛 판이어도 새 글을 받으니, 앱 이야기를 쓸 때 새 판을 내지 않아도 됩니다.
  • +
+
+ +
+
+ +
+
+

정한 것과 구현 때 맞출 것

+

정할 것 문서(2026-11-10, 목록형 · 게시판 꼴)를 따르고, 문서에 없어 보드에서 정한 것을 함께 적었습니다.

+
+
+ +
+

자리, 주소, API

+
    +
  • 웹은 설정 메뉴의 셋째 묶음 정보 아래 업데이트 내역입니다(계정, 메일 다음). 아이콘은 이미 있는 문서 그림(file-text)입니다.
  • +
  • 웹 주소는 목록 /settings/info/release-notes, 글 /settings/info/release-notes/{id}입니다. id는 파일 이름(예 2026-11-18)이고, 글을 보는 동안에도 메뉴의 업데이트 내역이 고른 상태입니다. 탭 제목은 둘 다 업데이트 내역입니다(다른 화면처럼 라우트마다 정한 이름).
  • +
  • 앱은 설정 탭 앱 정보 절의 앱 버전 다음 줄입니다. 목록 /settings/release-notes, 글 /settings/release-notes/{id}이고 둘 다 탭 막대 없는 전체 화면입니다.
  • +
  • 목록 GET /api/release-notes?cursor=&limit= 는 { items: [{ id, date, title }], pageInfo }입니다. 최근 글부터, 기본 20, 최대 50.
  • +
  • 글 GET /api/release-notes/{id} 는 { id, date, title, sections: [{ heading, items }], previous, next }입니다. previous(더 옛 글)와 next(더 새 글)는 { id, date, title } 또는 null이고, 없는 글은 404 RES_001입니다. 올리는 API는 없습니다.
  • +
  • 웹과 앱이 같은 목록이고 거르지 않습니다. 새 글 표시(점, 개수, NEW)와 번호, 작성자, 조회 수 칸은 두지 않습니다.
  • +
+
+ +
+

목록과 글의 모양

+
    +
  • 웹 목록은 머리 줄(제목, 날짜 32)이 있는 표 꼴이고, 설정의 로그인 기록 표와 같은 상자입니다. 줄(48 이상) 전체가 글로 가는 링크라 마우스를 올리면 옅은 청록 바탕, 키보드 초점은 안쪽 청록 테두리입니다. 제목이 길면 줄을 바꾸고 날짜는 오른쪽 칸(136)에 둡니다.
  • +
  • 목록 칸이 480보다 좁으면 머리 줄을 감추고 날짜를 제목 아래로 내립니다(A3).
  • +
  • 웹 글 화면은 위에 [← 목록으로] 줄(목록 보기에서 연 메일과 같은 꼴, 52), 그 아래 날짜 12.5, 제목 18(h1), 묶음 머리 14(h2), 줄 13(줄 간격 1.6), 글 폭 680까지입니다.
  • +
  • 글 끝에는 다른 글 줄(다음 글이 위, 이전 글이 아래, 목록과 같은 차례)과 [목록]을 둡니다. 없는 쪽의 줄은 두지 않습니다. 다른 글 줄은 목록 줄처럼 줄 전체를 누릅니다.
  • +
  • 앱 목록 줄은 제목 16, 날짜 13, 끝에 ›이고 68(설정 탭의 두 줄 칸과 같다)입니다. 앱 글 화면의 위 막대는 뒤로만이고 날짜 13, 제목 18, 머리 15, 줄 15(줄 간격 1.6), 끝에 다른 글 줄(64)이 있습니다. [목록] 단추는 두지 않습니다.
  • +
  • 대괄호 글은 꾸미지 않고 쓴 그대로 둡니다. 웹 설명 한 줄은 목록 화면에만 둡니다.
  • +
+
+ 목록 줄 · 보통, 마우스를 올렸을 때, 키보드 초점 +
+
화면의 안내 글을 다듬었습니다2026년 11월 5일
+
왼쪽 메뉴를 정리했습니다2026년 11월 4일
+
2단계 인증과 앱 비밀번호를 쓸 수 있습니다2026년 11월 3일
+
+
+
+ +
+

상태와 [더 보기]

+
    +
  • 목록은 처음 20개를 받고, 더 있으면 목록 아래 [더 보기]로 20개씩 더 받습니다(웹 44, 앱 48 폭 전체). 받는 동안 그 단추만 바쁨 표시이고, 더 받지 못하면 알림으로 알리고 받은 줄은 그대로 둡니다.
  • +
  • 목록을 불러오는 중에는 줄 자리를 막대로 잡아 둡니다. 받지 못하면 그 자리에 사전 글, [다시 시도], 문의 코드를 두고 알림은 띄우지 않습니다. 글이 없으면 "아직 알려 드릴 업데이트가 없습니다." 한 줄입니다.
  • +
  • 글 화면을 받는 동안은 글 자리를 막대로 잡아 둡니다(목록과 같은 결, 보드에는 그리지 않았습니다). 없는 글(지워졌거나 주소가 틀림, 404 RES_001)은 사전 글과 [목록으로], 그 밖의 실패는 사전 글과 [다시 시도]입니다.
  • +
  • 웹은 어떤 상태에서도 [← 목록으로] 줄이 위에 남습니다(목록 보기에서 연 메일과 같다). 앱은 위 막대의 뒤로가 늘 목록입니다.
  • +
+
+
+ 웹 [더 보기] · 받는 동안 + +
+
+ 앱 [더 보기] · 받는 동안 + 더 보기 +
+
+
+ +
+

구현 때 맞출 것

+
    +
  • 웹 상태는 지금의 MailLoadError(없는 글은 이름을 목록으로 둔 leaveLabel)와 StateMessage 그대로입니다. 위의 [← 목록으로]는 목록 보기 읽기 화면의 줄(MailToolButton accent)과 같은 꼴입니다.
  • +
  • 앱의 받지 못함, 빈 목록 틀(MailStateLayout, MailListError)과 불러오는 중 막대(MailListSkeleton)는 shared 쪽으로 옮겨 함께 씁니다. 받지 못함과 없는 글의 제목은 긴 문장이라 15, 글이 없음의 제목은 빈 메일함과 같은 크기입니다.
  • +
  • 앱 빈 목록의 그림은 웹과 같은 문서 그림으로 맞춥니다(Material 의 article_outlined 쯤).
  • +
  • 첫 글의 파일 이름(날짜)은 배포하는 날로 고칩니다(보드는 2026-11-18). 이름을 고치면 글 주소도 바뀝니다.
  • +
  • 웹 좁은 폭은 지금 설정 화면 그대로라, 휴대폰 브라우저에서는 목록도 글도 메뉴 아래로 내려야 나옵니다(A3).
  • +
+
+ +
+
+ +
+
+ + + diff --git a/docs/design/webmail/canvas.json b/docs/design/webmail/canvas.json index c4ae660..0c59b54 100644 --- a/docs/design/webmail/canvas.json +++ b/docs/design/webmail/canvas.json @@ -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": [] } -- 2.52.0 From cd958f3bf481ea63d559fdab50e92820cd738d70 Mon Sep 17 00:00:00 2001 From: ttipo Date: Tue, 10 Nov 2026 19:36:59 +0900 Subject: [PATCH 03/12] =?UTF-8?q?feat(backend):=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=EB=82=B4=EC=97=AD=20=EC=9D=BD=EA=B8=B0=20?= =?UTF-8?q?API=20=EC=99=80=20=EC=B2=AB=20=EA=B8=80=EC=9D=84=20=EB=8D=94?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 직원이 웹메일 · 휴대폰 앱에서 무엇이 새로 생겼는지 읽는 게시판의 서버 몫이다. 글은 운영자가 backend/release-notes/ 에 글마다 파일 하나로 쓰고 (올리는 API · DB 표 · 새 에러 코드 없음), GET /api/release-notes(커서 목록)와 GET /api/release-notes/{id}(글 + 바로 옛 글 · 새 글)로 웹 · 앱에 똑같이 준다. - 글 폴더는 src 밖이라 개발 컨테이너(바인드 마운트)는 다시 띄우지 않아도 새 글이 보이고, 운영 이미지는 Dockerfile 이 폴더를 함께 복사한다 - 틀린 글은 실행 때 그 글만 건너뛰고 error 로그(파일 이름 · 줄 · 까닭만 — 기동 때 한 번 읽어 배포 직후 보인다), 실제 폴더를 엄격하게 읽는 시험이 CI 에서 운영자의 오타를 잡는다 - 파일은 O_NOFOLLOW 로 한 번 열어 일반 파일 · 256KB 까지만 읽고, 모양이 틀린 id 도 없는 글과 같은 404 RES_001 이다 - 첫 글(2026-11-18.md)은 다음 배포 소식이다 — 파일 이름(날짜)은 배포 날로 고친다 Co-Authored-By: Claude Opus 5.5 --- backend/Dockerfile | 2 + backend/release-notes/2026-11-18.md | 26 + backend/release-notes/README.md | 41 ++ backend/src/app.module.ts | 3 + .../release-notes/dto/release-note.dto.ts | 97 ++++ .../release-notes/release-note-format.spec.ts | 538 ++++++++++++++++++ .../release-notes/release-note-format.ts | 448 +++++++++++++++ .../release-note-id.pipe.spec.ts | 34 ++ .../release-notes/release-note-id.pipe.ts | 19 + .../release-notes-folder.spec.ts | 347 +++++++++++ .../release-notes/release-notes-folder.ts | 333 +++++++++++ .../release-notes/release-notes.constants.ts | 54 ++ .../release-notes.content.spec.ts | 43 ++ .../release-notes/release-notes.controller.ts | 87 +++ .../release-notes/release-notes.module.ts | 23 + .../release-notes.service.spec.ts | 186 ++++++ .../release-notes/release-notes.service.ts | 87 +++ .../release-notes/release-notes.types.ts | 30 + backend/test/app.e2e-spec.ts | 282 ++++++++- 19 files changed, 2679 insertions(+), 1 deletion(-) create mode 100644 backend/release-notes/2026-11-18.md create mode 100644 backend/release-notes/README.md create mode 100644 backend/src/modules/release-notes/dto/release-note.dto.ts create mode 100644 backend/src/modules/release-notes/release-note-format.spec.ts create mode 100644 backend/src/modules/release-notes/release-note-format.ts create mode 100644 backend/src/modules/release-notes/release-note-id.pipe.spec.ts create mode 100644 backend/src/modules/release-notes/release-note-id.pipe.ts create mode 100644 backend/src/modules/release-notes/release-notes-folder.spec.ts create mode 100644 backend/src/modules/release-notes/release-notes-folder.ts create mode 100644 backend/src/modules/release-notes/release-notes.constants.ts create mode 100644 backend/src/modules/release-notes/release-notes.content.spec.ts create mode 100644 backend/src/modules/release-notes/release-notes.controller.ts create mode 100644 backend/src/modules/release-notes/release-notes.module.ts create mode 100644 backend/src/modules/release-notes/release-notes.service.spec.ts create mode 100644 backend/src/modules/release-notes/release-notes.service.ts create mode 100644 backend/src/modules/release-notes/release-notes.types.ts diff --git a/backend/Dockerfile b/backend/Dockerfile index 4e18384..c3ee50f 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -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 가 쓸 수 있다. diff --git a/backend/release-notes/2026-11-18.md b/backend/release-notes/2026-11-18.md new file mode 100644 index 0000000..02e088e --- /dev/null +++ b/backend/release-notes/2026-11-18.md @@ -0,0 +1,26 @@ +# 메일 검색이 빨라지고 휴대폰 앱이 나왔습니다 + + +## 휴대폰 앱 +- 안드로이드 휴대폰 앱이 나왔습니다. 웹메일의 [설정] → [휴대폰 앱]에서 받을 수 있습니다. +- 앱에서 메일을 읽고 쓰고 찾을 수 있고, 새 메일이 오면 휴대폰으로 알려 드립니다. + 알림에는 새 메일이 몇 통인지만 나오고, 보낸 사람이나 제목은 나오지 않습니다. + +## 웹메일과 휴대폰 앱 +- 메일 검색이 빨라졌습니다. 보낸 사람, 받는 사람, 제목, 본문, 기간, 첨부로 나눠 찾을 수도 있습니다. +- 찾은 말에는 노란 표시가 붙습니다. +- 메일을 읽다가 바로 다른 메일함으로 옮길 수 있습니다. [이동] + +## 웹메일 +- 메일을 열면 같은 대화의 메일을 그 아래에서 함께 볼 수 있습니다. 목록 위 [대화로 묶기]를 켜면 목록도 대화별로 묶입니다. +- 목록의 메일을 끌어다 왼쪽 메일함에 놓아도 옮겨집니다. +- 메일함을 다른 메일함 아래로 옮기거나, 하위 메일함까지 한꺼번에 지울 수 있습니다. +- 보낸 사람을 차단할 때 받은편지함에 있는 그 사람의 메일도 함께 정리할 수 있습니다. +- 예약한 메일을 기다리지 않고 바로 보낼 수 있습니다. [지금 보내기] + +## 웹메일 · 주소록과 설정 +- 다른 메일이나 휴대폰에서 내보낸 연락처 파일(vCard, CSV, 엑셀)을 한 번에 가져올 수 있습니다. [주소록] → [가져오기] +- 내 주소록을 파일로 내보낼 수 있습니다. [주소록] → [내보내기] +- 서명을 10개까지 만들고, 메일을 쓰다가 [서명]으로 바꿀 수 있습니다. +- 부재중 자동 응답을 글자 모양을 꾸며 쓸 수 있고, 회사 밖에서 온 메일에는 다르게 답하거나 답하지 않을 수 있습니다. +- 휴대폰을 바꾸셨다면 2단계 인증을 새 휴대폰으로 직접 옮길 수 있습니다. [설정] → [2단계 인증] → [휴대폰 바꾸기] diff --git a/backend/release-notes/README.md b/backend/release-notes/README.md new file mode 100644 index 0000000..5e8f224 --- /dev/null +++ b/backend/release-notes/README.md @@ -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`를 돌립니다. 그래도 배포되면 서버는 그 글만 건너뛰고 로그에 파일 이름과 줄 번호를 남깁니다. 서버는 그대로 뜹니다. +- 앱은 옛 판이어도 새 글을 받으니, 앱 이야기를 쓸 때 새 판을 내지 않아도 됩니다. diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 2e8f798..62bd3e6 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -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, ], diff --git a/backend/src/modules/release-notes/dto/release-note.dto.ts b/backend/src/modules/release-notes/dto/release-note.dto.ts new file mode 100644 index 0000000..c807072 --- /dev/null +++ b/backend/src/modules/release-notes/dto/release-note.dto.ts @@ -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; +} diff --git a/backend/src/modules/release-notes/release-note-format.spec.ts b/backend/src/modules/release-notes/release-note-format.spec.ts new file mode 100644 index 0000000..ee91feb --- /dev/null +++ b/backend/src/modules/release-notes/release-note-format.spec.ts @@ -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, 글 안의 ', + '- 한 줄 ', + ' 뒤에 글', + ' 닫은 뒤 글', + '- 둘째 줄', + ''], 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); + }); +}); diff --git a/backend/src/modules/release-notes/release-note-format.ts b/backend/src/modules/release-notes/release-note-format.ts new file mode 100644 index 0000000..0f8adf9 --- /dev/null +++ b/backend/src/modules/release-notes/release-note-format.ts @@ -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': + '글 안에 메모('; +/** `` · `` 도 닫힌 메모다(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]})`; +} diff --git a/backend/src/modules/release-notes/release-note-id.pipe.spec.ts b/backend/src/modules/release-notes/release-note-id.pipe.spec.ts new file mode 100644 index 0000000..2799971 --- /dev/null +++ b/backend/src/modules/release-notes/release-note-id.pipe.spec.ts @@ -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'); + } + }); +}); diff --git a/backend/src/modules/release-notes/release-note-id.pipe.ts b/backend/src/modules/release-notes/release-note-id.pipe.ts new file mode 100644 index 0000000..ed87fe9 --- /dev/null +++ b/backend/src/modules/release-notes/release-note-id.pipe.ts @@ -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 { + transform(value: unknown): string { + if (isReleaseNoteId(value)) return value; + throw new BusinessException('RES_001', { + context: { reason: 'malformed-release-note-id' }, + }); + } +} diff --git a/backend/src/modules/release-notes/release-notes-folder.spec.ts b/backend/src/modules/release-notes/release-notes-folder.spec.ts new file mode 100644 index 0000000..d8851da --- /dev/null +++ b/backend/src/modules/release-notes/release-notes-folder.spec.ts @@ -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(); + } + }); +}); diff --git a/backend/src/modules/release-notes/release-notes-folder.ts b/backend/src/modules/release-notes/release-notes-folder.ts new file mode 100644 index 0000000..2f0fe7a --- /dev/null +++ b/backend/src/modules/release-notes/release-notes-folder.ts @@ -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 { + 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 { + 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, +): 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(); + /** 앞서 본 폴더의 모양(이름 · 크기 · 바뀐 시각) — 같으면 앞의 결과를 그대로 */ + private signature: string | null = null; + private snapshot: ReleaseNotesSnapshot = EMPTY_SNAPSHOT; + private loading: Promise | null = null; + private warnedMissing = false; + + constructor(readonly dir: string = RELEASE_NOTES_DIR) {} + + /** + * 기동 때 한 번 읽는다 — 틀린 글의 error 로그가 첫 요청이 아니라 배포 직후의 로그에 보이게. + * 읽지 못해도 던지지 않는다(기동을 막지 않는다 — 요청 때 다시 읽는다) + */ + async onApplicationBootstrap(): Promise { + try { + await this.read(); + } catch (error) { + this.logger.error( + `[SYS_001] 업데이트 내역 폴더를 기동 때 읽지 못했습니다 — 요청 때 다시 읽습니다: dir=${this.dir}, reason=${reasonOf(error)}`, + ); + } + } + + /** 지금 폴더의 글 · 문제 — 읽는 중이면 그 결과를 함께 기다린다 */ + read(): Promise { + if (this.loading !== null) return this.loading; + const loading = this.load().finally(() => { + this.loading = null; + }); + this.loading = loading; + return loading; + } + + private async load(): Promise { + 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(); + 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 { + 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 { + 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 { + 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; + } +} diff --git a/backend/src/modules/release-notes/release-notes.constants.ts b/backend/src/modules/release-notes/release-notes.constants.ts new file mode 100644 index 0000000..11e1247 --- /dev/null +++ b/backend/src/modules/release-notes/release-notes.constants.ts @@ -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'; diff --git a/backend/src/modules/release-notes/release-notes.content.spec.ts b/backend/src/modules/release-notes/release-notes.content.spec.ts new file mode 100644 index 0000000..c35a7d1 --- /dev/null +++ b/backend/src/modules/release-notes/release-notes.content.spec.ts @@ -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, + ); + }); +}); diff --git a/backend/src/modules/release-notes/release-notes.controller.ts b/backend/src/modules/release-notes/release-notes.controller.ts new file mode 100644 index 0000000..bc3dc6f --- /dev/null +++ b/backend/src/modules/release-notes/release-notes.controller.ts @@ -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> { + 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 { + return this.releaseNotes.get(id); + } +} diff --git a/backend/src/modules/release-notes/release-notes.module.ts b/backend/src/modules/release-notes/release-notes.module.ts new file mode 100644 index 0000000..8298b6a --- /dev/null +++ b/backend/src/modules/release-notes/release-notes.module.ts @@ -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 {} diff --git a/backend/src/modules/release-notes/release-notes.service.spec.ts b/backend/src/modules/release-notes/release-notes.service.spec.ts new file mode 100644 index 0000000..7ad2659 --- /dev/null +++ b/backend/src/modules/release-notes/release-notes.service.spec.ts @@ -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> = {}, +): CursorPaginationQueryDto { + return Object.assign(new CursorPaginationQueryDto(), values); +} + +/** 던진 업무 오류의 코드 */ +async function codeOf(work: Promise): Promise { + 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'); + }); +}); diff --git a/backend/src/modules/release-notes/release-notes.service.ts b/backend/src/modules/release-notes/release-notes.service.ts new file mode 100644 index 0000000..e66cb1d --- /dev/null +++ b/backend/src/modules/release-notes/release-notes.service.ts @@ -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> { + 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 { + 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, + }; + } +} diff --git a/backend/src/modules/release-notes/release-notes.types.ts b/backend/src/modules/release-notes/release-notes.types.ts new file mode 100644 index 0000000..f9dd08f --- /dev/null +++ b/backend/src/modules/release-notes/release-notes.types.ts @@ -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[]; +} diff --git a/backend/test/app.e2e-spec.ts b/backend/test/app.e2e-spec.ts index 09601d9..65aebbf 100644 --- a/backend/test/app.e2e-spec.ts +++ b/backend/test/app.e2e-spec.ts @@ -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(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( + 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(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(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(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(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(list).items).toEqual([added]); + expect(dataOf(list).pageInfo).toMatchObject({ + hasNext: true, + total: 5, + }); + + const top = await as(request(server).get(`${LIST}/${TOP.id}`)).expect( + 200, + ); + expect(dataOf(top).next).toEqual(added); + } finally { + unlinkSync(join(notesDir, `${added.id}.md`)); + } + const after = await as(request(server).get(LIST)).expect(200); + expect(dataOf(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(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 형식 · 검사 · 잠금 · 응답 꼴을 본다 -- 2.52.0 From 1b3f9ba7fecace778c3fe65f27a7211bcfed3d9b Mon Sep 17 00:00:00 2001 From: ttipo Date: Tue, 10 Nov 2026 19:36:59 +0900 Subject: [PATCH 04/12] =?UTF-8?q?refactor(flutter):=20=EB=A9=94=EC=9D=BC?= =?UTF-8?q?=20=ED=99=94=EB=A9=B4=EC=9D=98=20=EB=B6=88=EB=9F=AC=EC=98=A4?= =?UTF-8?q?=EA=B8=B0=20=EC=83=81=ED=83=9C=20=EC=9C=84=EC=A0=AF=EC=9D=84=20?= =?UTF-8?q?shared=20=EB=A1=9C=20=EC=98=AE=EA=B8=B4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 업데이트 내역(설정 모듈)도 같은 상태 틀을 쓰는데 모듈끼리 import 하지 않으므로 shared/widgets/load_states.dart 로 옮겼다. 모양 · 동작은 그대로다(MailStateLayout → StateLayout · MailListError → LoadErrorView · 막대 SkeletonBar · LoadingSkeleton). 메일 줄 꼴의 막대와 빈 메일함 등은 메일에 남는다. Co-Authored-By: Claude Opus 5.5 --- .../mail/presentation/mail_texts.dart | 7 +- .../presentation/pages/mail_list_page.dart | 9 +- .../presentation/pages/mail_search_page.dart | 3 +- .../pages/message_compose_page.dart | 4 +- .../pages/message_reader_page.dart | 4 +- .../widgets/mail_list_states.dart | 252 ++++-------------- .../widgets/mail_search_states.dart | 10 +- .../lib/shared/widgets/load_state_texts.dart | 11 + flutter/lib/shared/widgets/load_states.dart | 208 +++++++++++++++ 9 files changed, 288 insertions(+), 220 deletions(-) create mode 100644 flutter/lib/shared/widgets/load_state_texts.dart create mode 100644 flutter/lib/shared/widgets/load_states.dart diff --git a/flutter/lib/features/mail/presentation/mail_texts.dart b/flutter/lib/features/mail/presentation/mail_texts.dart index 2ac2c16..c17ecd9 100644 --- a/flutter/lib/features/mail/presentation/mail_texts.dart +++ b/flutter/lib/features/mail/presentation/mail_texts.dart @@ -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); // ── 빈 메일함 ── /// "휴지통이 비어 있습니다" diff --git a/flutter/lib/features/mail/presentation/pages/mail_list_page.dart b/flutter/lib/features/mail/presentation/pages/mail_list_page.dart index f41be4f..dc9336c 100644 --- a/flutter/lib/features/mail/presentation/pages/mail_list_page.dart +++ b/flutter/lib/features/mail/presentation/pages/mail_list_page.dart @@ -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( diff --git a/flutter/lib/features/mail/presentation/pages/mail_search_page.dart b/flutter/lib/features/mail/presentation/pages/mail_search_page.dart index 3b19957..64ae6ab 100644 --- a/flutter/lib/features/mail/presentation/pages/mail_search_page.dart +++ b/flutter/lib/features/mail/presentation/pages/mail_search_page.dart @@ -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) 다시 해도 같다 — 전체 메일함에서 찾는다 diff --git a/flutter/lib/features/mail/presentation/pages/message_compose_page.dart b/flutter/lib/features/mail/presentation/pages/message_compose_page.dart index 25290a7..bccd38f 100644 --- a/flutter/lib/features/mail/presentation/pages/message_compose_page.dart +++ b/flutter/lib/features/mail/presentation/pages/message_compose_page.dart @@ -20,9 +20,9 @@ import 'package:arasystem_app/features/mail/presentation/providers/mail_toast_pr import 'package:arasystem_app/features/mail/presentation/widgets/compose_bars.dart'; import 'package:arasystem_app/features/mail/presentation/widgets/compose_recipient_field.dart'; import 'package:arasystem_app/features/mail/presentation/widgets/compose_sections.dart'; -import 'package:arasystem_app/features/mail/presentation/widgets/mail_list_states.dart'; import 'package:arasystem_app/features/mail/presentation/widgets/reader_notices.dart'; import 'package:arasystem_app/shared/widgets/confirm_dialog.dart'; +import 'package:arasystem_app/shared/widgets/load_states.dart'; import 'package:flutter/material.dart'; import 'package:flutter/semantics.dart'; import 'package:flutter/services.dart'; @@ -749,7 +749,7 @@ class _ComposeError extends StatelessWidget { SliverFillRemaining( hasScrollBody: false, child: Center( - child: MailListError( + child: LoadErrorView( error: error, onRetry: onRetry, onLeave: onLeave, diff --git a/flutter/lib/features/mail/presentation/pages/message_reader_page.dart b/flutter/lib/features/mail/presentation/pages/message_reader_page.dart index d772425..b3b95c5 100644 --- a/flutter/lib/features/mail/presentation/pages/message_reader_page.dart +++ b/flutter/lib/features/mail/presentation/pages/message_reader_page.dart @@ -22,13 +22,13 @@ import 'package:arasystem_app/features/mail/presentation/providers/message_reade import 'package:arasystem_app/features/mail/presentation/providers/message_reader_state.dart'; import 'package:arasystem_app/features/mail/presentation/widgets/mail_body_view.dart'; import 'package:arasystem_app/features/mail/presentation/widgets/mail_compose_area.dart'; -import 'package:arasystem_app/features/mail/presentation/widgets/mail_list_states.dart'; import 'package:arasystem_app/features/mail/presentation/widgets/mail_move_sheet.dart'; import 'package:arasystem_app/features/mail/presentation/widgets/reader_attachments.dart'; import 'package:arasystem_app/features/mail/presentation/widgets/reader_bars.dart'; import 'package:arasystem_app/features/mail/presentation/widgets/reader_header.dart'; import 'package:arasystem_app/features/mail/presentation/widgets/reader_notices.dart'; import 'package:arasystem_app/shared/widgets/confirm_dialog.dart'; +import 'package:arasystem_app/shared/widgets/load_states.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -533,7 +533,7 @@ class _ReaderError extends StatelessWidget { SliverFillRemaining( hasScrollBody: false, child: Center( - child: MailListError( + child: LoadErrorView( error: error, onRetry: onRetry, onLeave: onLeave, diff --git a/flutter/lib/features/mail/presentation/widgets/mail_list_states.dart b/flutter/lib/features/mail/presentation/widgets/mail_list_states.dart index 9f9ccd9..fd415ea 100644 --- a/flutter/lib/features/mail/presentation/widgets/mail_list_states.dart +++ b/flutter/lib/features/mail/presentation/widgets/mail_list_states.dart @@ -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()!; - 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()!; - 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: [ - 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 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: [ - 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: [ - 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: [ - 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: [ - 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: [ + 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: [ + 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: [ + 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), + ], + ), + ), + ], + ), + ), + ], ), ); } diff --git a/flutter/lib/features/mail/presentation/widgets/mail_search_states.dart b/flutter/lib/features/mail/presentation/widgets/mail_search_states.dart index ac811ce..5428369 100644 --- a/flutter/lib/features/mail/presentation/widgets/mail_search_states.dart +++ b/flutter/lib/features/mail/presentation/widgets/mail_search_states.dart @@ -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()!; - 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()!; - return MailStateLayout( + return StateLayout( icon: Icons.error_outline, iconColor: extra.textMuted, iconBackground: theme.colorScheme.surfaceContainerHigh, diff --git a/flutter/lib/shared/widgets/load_state_texts.dart b/flutter/lib/shared/widgets/load_state_texts.dart new file mode 100644 index 0000000..1337442 --- /dev/null +++ b/flutter/lib/shared/widgets/load_state_texts.dart @@ -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'; +} diff --git a/flutter/lib/shared/widgets/load_states.dart b/flutter/lib/shared/widgets/load_states.dart new file mode 100644 index 0000000..8a64db5 --- /dev/null +++ b/flutter/lib/shared/widgets/load_states.dart @@ -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 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: [ + 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()!; + 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: [ + 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, + ), + ), + ); + } +} -- 2.52.0 From 1d01130c9d83ec509ebb0340b10d7b751695dc27 Mon Sep 17 00:00:00 2001 From: ttipo Date: Tue, 10 Nov 2026 19:37:00 +0900 Subject: [PATCH 05/12] =?UTF-8?q?feat(flutter):=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=E2=80=BA=20=EC=95=B1=20=EC=A0=95=EB=B3=B4=EC=97=90=20=EC=97=85?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=8A=B8=20=EB=82=B4=EC=97=AD(=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=E2=86=92=20=EA=B8=80)=EC=9D=84=20=EB=8D=94?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 앱 버전 다음 줄에서 목록(제목 · 날짜, [더 보기])으로 들어가 줄을 누르면 글(날짜 · 제목 · 묶음, 다음 글 · 이전 글 줄)이 열린다. 없는 글 · 받지 못함 · 글 없음 상태를 두고, 날짜는 달력 날짜 글이라 시각으로 바꾸지 않는다(CalendarDate). 다른 글 줄은 지금 글을 바꿔 끼워 뒤로는 늘 목록이다. Co-Authored-By: Claude Opus 5.5 --- flutter/lib/core/theme/app_spacing.dart | 14 + flutter/lib/core/utils/calendar_date.dart | 53 ++ flutter/lib/core/utils/korean_format.dart | 8 + flutter/lib/features/settings/data/.gitkeep | 0 .../settings/data/models/release_note.dart | 190 +++++++ .../release_notes_repository.dart | 66 +++ .../presentation/pages/release_note_page.dart | 428 ++++++++++++++++ .../pages/release_notes_page.dart | 350 +++++++++++++ .../presentation/pages/settings_page.dart | 14 +- .../providers/release_note_controller.dart | 97 ++++ .../release_notes_list_controller.dart | 163 ++++++ .../providers/release_notes_providers.dart | 25 + .../presentation/release_notes_texts.dart | 44 ++ .../settings/presentation/settings_texts.dart | 1 + .../widgets/release_notes_parts.dart | 105 ++++ .../features/settings/settings_routes.dart | 37 +- flutter/test/app_test.dart | 19 +- .../test/core/utils/calendar_date_test.dart | 64 +++ .../settings/release_notes_page_test.dart | 477 +++++++++++++++++ .../features/settings/release_notes_test.dart | 483 ++++++++++++++++++ .../features/settings/settings_page_test.dart | 120 +++-- flutter/test/support/release_notes_fakes.dart | 203 ++++++++ 22 files changed, 2916 insertions(+), 45 deletions(-) create mode 100644 flutter/lib/core/utils/calendar_date.dart delete mode 100644 flutter/lib/features/settings/data/.gitkeep create mode 100644 flutter/lib/features/settings/data/models/release_note.dart create mode 100644 flutter/lib/features/settings/data/repositories/release_notes_repository.dart create mode 100644 flutter/lib/features/settings/presentation/pages/release_note_page.dart create mode 100644 flutter/lib/features/settings/presentation/pages/release_notes_page.dart create mode 100644 flutter/lib/features/settings/presentation/providers/release_note_controller.dart create mode 100644 flutter/lib/features/settings/presentation/providers/release_notes_list_controller.dart create mode 100644 flutter/lib/features/settings/presentation/providers/release_notes_providers.dart create mode 100644 flutter/lib/features/settings/presentation/release_notes_texts.dart create mode 100644 flutter/lib/features/settings/presentation/widgets/release_notes_parts.dart create mode 100644 flutter/test/core/utils/calendar_date_test.dart create mode 100644 flutter/test/features/settings/release_notes_page_test.dart create mode 100644 flutter/test/features/settings/release_notes_test.dart create mode 100644 flutter/test/support/release_notes_fakes.dart diff --git a/flutter/lib/core/theme/app_spacing.dart b/flutter/lib/core/theme/app_spacing.dart index cdf5dc3..5c32e74 100644 --- a/flutter/lib/core/theme/app_spacing.dart +++ b/flutter/lib/core/theme/app_spacing.dart @@ -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; } diff --git a/flutter/lib/core/utils/calendar_date.dart b/flutter/lib/core/utils/calendar_date.dart new file mode 100644 index 0000000..ed7c6b8 --- /dev/null +++ b/flutter/lib/core/utils/calendar_date.dart @@ -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')}'; +} diff --git a/flutter/lib/core/utils/korean_format.dart b/flutter/lib/core/utils/korean_format.dart index 76493e0..f3afd1b 100644 --- a/flutter/lib/core/utils/korean_format.dart +++ b/flutter/lib/core/utils/korean_format.dart @@ -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; diff --git a/flutter/lib/features/settings/data/.gitkeep b/flutter/lib/features/settings/data/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/flutter/lib/features/settings/data/models/release_note.dart b/flutter/lib/features/settings/data/models/release_note.dart new file mode 100644 index 0000000..8a6797a --- /dev/null +++ b/flutter/lib/features/settings/data/models/release_note.dart @@ -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) 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 items; + + /// 모양이 틀리면 null + static ReleaseNoteSection? tryParse(Object? json) { + if (json is! Map) return null; + final Object? heading = json['heading']; + final Object? items = json['items']; + if ((heading != null && heading is! String) || items is! List) { + return null; + } + final List lines = []; + for (final Object? item in items) { + if (item is! String) return null; + lines.add(item); + } + return ReleaseNoteSection( + heading: heading as String?, + items: List.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 sections; + + /// 바로 더 옛 글(목록에서 아래) — 없으면 null. 글 화면 끝의 "이전 글" 줄 + final ReleaseNoteSummary? previous; + + /// 바로 더 새 글(목록에서 위) — 없으면 null. 글 화면 끝의 "다음 글" 줄 + final ReleaseNoteSummary? next; + + /// 모양이 틀리면(묶음 · 다른 글 하나라도) null. `previous` · `next` 칸이 아예 없으면 없는 것(null)으로 본다 + static ReleaseNote? tryParse(Object? json) { + if (json is! Map) return null; + final ReleaseNoteSummary? head = ReleaseNoteSummary.tryParse(json); + final Object? rawSections = json['sections']; + if (head == null || rawSections is! List) return null; + final List sections = []; + 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.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 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) return null; + final Object? rawItems = json['items']; + final Object? pageInfo = json['pageInfo']; + if (rawItems is! List || pageInfo is! Map) { + 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 items = []; + for (final Object? raw in rawItems) { + final ReleaseNoteSummary? item = ReleaseNoteSummary.tryParse(raw); + if (item == null) return null; + items.add(item); + } + return ReleaseNotesPage( + items: List.unmodifiable(items), + hasNext: hasNext, + nextCursor: nextCursor as String?, + total: (total as num?)?.toInt(), + ); + } +} diff --git a/flutter/lib/features/settings/data/repositories/release_notes_repository.dart b/flutter/lib/features/settings/data/repositories/release_notes_repository.dart new file mode 100644 index 0000000..205102d --- /dev/null +++ b/flutter/lib/features/settings/data/repositories/release_notes_repository.dart @@ -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 listNotes({ + String? cursor, + int limit = kReleaseNotesPageSize, + bool silent = false, + }) async { + final Response response = await _dio.get( + ReleaseNotesApiPaths.releaseNotes, + queryParameters: {'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 getNote(String id) async { + final Response response = await _dio.get( + 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 response) + : super( + requestOptions: response.requestOptions, + error: ApiException.fromCode(kFallbackCode), + ); +} diff --git a/flutter/lib/features/settings/presentation/pages/release_note_page.dart b/flutter/lib/features/settings/presentation/pages/release_note_page.dart new file mode 100644 index 0000000..dceb1c7 --- /dev/null +++ b/flutter/lib/features/settings/presentation/pages/release_note_page.dart @@ -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 _skeletonLineWidths = [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 onOpen; + + @override + Widget build(BuildContext context) { + final ReleaseNoteSummary? newer = note.next; + final ReleaseNoteSummary? older = note.previous; + return ListView( + children: [ + _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: [ + 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) ...[ + 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 items = section.items; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (heading != null) ...[ + 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++) ...[ + 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()!; + 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: [ + 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 onOpen; + + @override + Widget build(BuildContext context) { + final Color line = Theme.of( + context, + ).extension()!.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: [ + 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()!; + 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: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: AppSpacing.s2, + children: [ + Text( + ReleaseNotesTexts.neighborLine(label, date), + semanticsLabel: ReleaseNotesTexts.neighborLineSpoken( + label, + date, + ), + style: theme.textTheme.bodySmall?.copyWith( + color: extra.textMuted, + height: AppTypography.lineHeightHeading, + fontFeatures: const [ + 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()!.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: [ + 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) ...[ + const SizedBox(height: AppSpacing.s14), + FractionallySizedBox( + widthFactor: width, + child: SkeletonBar(color: bar, rounded: true), + ), + ], + ], + ), + ), + ); + } +} diff --git a/flutter/lib/features/settings/presentation/pages/release_notes_page.dart b/flutter/lib/features/settings/presentation/pages/release_notes_page.dart new file mode 100644 index 0000000..cbdbd34 --- /dev/null +++ b/flutter/lib/features/settings/presentation/pages/release_notes_page.dart @@ -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 _skeletonTitleWidths = [ + 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/` + 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(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 onOpen; + final VoidCallback onMore; + + @override + Widget build(BuildContext context) { + final List 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()!; + 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: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: AppSpacing.s2, + children: [ + 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()!; + 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: [ + 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()!; + return StateLayout( + // 웹의 문서 그림(file-text — 접힌 모서리와 글 줄)과 같은 꼴 + icon: Icons.description_outlined, + iconColor: extra.textMuted, + iconBackground: theme.colorScheme.surfaceContainerHigh, + title: ReleaseNotesTexts.empty, + children: const [], + ); + } +} + +/// 처음 받는 동안 — 줄 자리의 막대(보드 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()!; + return LoadingSkeleton( + label: ReleaseNotesTexts.listLoading, + // 가로 화면처럼 낮은 화면에서는 넘치는 줄을 자른다(보드의 `overflow: hidden`) + child: SingleChildScrollView( + physics: const NeverScrollableScrollPhysics(), + child: Column( + children: [ + 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: [ + FractionallySizedBox( + widthFactor: width, + child: SkeletonBar( + color: scheme.surfaceContainerHighest, + height: AppSize.skeletonTitleBarHeight, + rounded: true, + ), + ), + SizedBox( + width: AppSize.skeletonDateBarWidth, + child: SkeletonBar( + color: extra.borderSubtle, + rounded: true, + ), + ), + ], + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/flutter/lib/features/settings/presentation/pages/settings_page.dart b/flutter/lib/features/settings/presentation/pages/settings_page.dart index 0603eac..b42f352 100644 --- a/flutter/lib/features/settings/presentation/pages/settings_page.dart +++ b/flutter/lib/features/settings/presentation/pages/settings_page.dart @@ -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 [], this.sectionLocation, super.key, @@ -39,6 +42,9 @@ class SettingsPage extends ConsumerStatefulWidget { /// 오픈소스 라이선스 화면의 경로 final String licensesLocation; + /// 업데이트 내역 목록의 경로 + final String releaseNotesLocation; + /// 다른 모듈이 내놓은 설정 절 final List sections; @@ -125,6 +131,12 @@ class _SettingsPageState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _VersionRow(version: version), + _LinkRow( + label: ReleaseNotesTexts.title, + onTap: () => unawaited( + context.push(widget.releaseNotesLocation), + ), + ), _LinkRow( label: AppDownloadTexts.getNewVersion, opensOutside: true, diff --git a/flutter/lib/features/settings/presentation/providers/release_note_controller.dart b/flutter/lib/features/settings/presentation/providers/release_note_controller.dart new file mode 100644 index 0000000..a49dad5 --- /dev/null +++ b/flutter/lib/features/settings/presentation/providers/release_note_controller.dart @@ -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 +releaseNoteProvider = NotifierProvider.autoDispose + .family( + ReleaseNoteController.new, + ); + +/// 업데이트 내역 글 — 계약 7절 `GET /api/release-notes/{id}` (보드 `ReleaseNotes` B3 · B4 · B5 · B6). +/// +/// 알림 없이 받는다 — 없는 글(지워졌거나 주소가 틀림 — 404 `RES_001`)과 그 밖의 실패는 화면이 그 자리에 그린다. +/// 세션이 바뀌면 처음부터 다시 받고, 그 전에 보낸 요청의 응답은 버린다(세대 [_seq] · 시작할 때의 [Ref]). +class ReleaseNoteController extends Notifier { + 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 retry() async { + if (!ref.mounted || ref.read(releaseNotesAccountProvider) == null) return; + _seq++; + state = const ReleaseNoteState(); + await _load(_seq); + } + + Future _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), + ); + } + } +} diff --git a/flutter/lib/features/settings/presentation/providers/release_notes_list_controller.dart b/flutter/lib/features/settings/presentation/providers/release_notes_list_controller.dart new file mode 100644 index 0000000..9b678a0 --- /dev/null +++ b/flutter/lib/features/settings/presentation/providers/release_notes_list_controller.dart @@ -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 [], + this.hasNext = false, + this.nextCursor, + this.error, + this.loadingMore = false, + }); + + final ReleaseNotesListStatus status; + + /// 받은 줄 — 최근 글부터(서버 차례 그대로) + final List 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 +releaseNotesListProvider = + NotifierProvider.autoDispose< + ReleaseNotesListController, + ReleaseNotesListState + >(ReleaseNotesListController.new); + +/// 업데이트 내역 목록 — 계약 7절 `GET /api/release-notes` (보드 `ReleaseNotes` B2 · B7 · B8 · B9). +/// +/// - 첫 쪽 · [다시 시도]는 알림 없이 받는다 — 받지 못하면 화면이 그 자리에 사전 문구 · 문의 코드 · [다시 시도]를 그린다. +/// - [더 보기]는 다음 쪽을 받아 뒤에 붙인다(이미 있는 글은 건너뛴다 — 그사이 글이 더해져 자리가 밀렸을 수 있다). +/// 받지 못하면 인터셉터가 알리고 받은 줄은 그대로 둔다(저절로 다시 하지 않는다 — [더 보기]를 다시 누른다). +/// - 늦은 응답: 처음부터 다시 받을 때마다(다시 시도 · 세션이 바뀜) 세대([_seq])가 오른다 — 그 전에 보낸 요청의 응답은 버린다. +/// 비동기 일은 시작할 때의 [Ref] 를 쥐고, 끝난 뒤 그 Ref 가 살아 있을 때만 상태를 바꾼다(다시 만들어졌거나 사라졌으면 버린다). +class ReleaseNotesListController extends Notifier { + 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 retry() async { + if (!ref.mounted || ref.read(releaseNotesAccountProvider) == null) return; + _seq++; + state = const ReleaseNotesListState(); + await _loadFirst(_seq); + } + + Future _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 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 known = { + for (final ReleaseNoteSummary note in state.items) note.id, + }; + state = ReleaseNotesListState( + status: ReleaseNotesListStatus.ready, + items: List.unmodifiable([ + ...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); + } + } +} diff --git a/flutter/lib/features/settings/presentation/providers/release_notes_providers.dart b/flutter/lib/features/settings/presentation/providers/release_notes_providers.dart new file mode 100644 index 0000000..afe9044 --- /dev/null +++ b/flutter/lib/features/settings/presentation/providers/release_notes_providers.dart @@ -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 releaseNotesRepositoryProvider = + Provider( + (Ref ref) => ReleaseNotesRepository(ref.watch(dioProvider)), + ); + +/// 업데이트 내역을 보는 직원 — 로그인했고 제한이 없을 때 그 주소, 그 밖에는 null. +/// +/// 목록 · 글은 이것을 듣고 바뀌면 처음부터 다시 받는다 — 로그아웃 · 세션 끝 · 다른 직원 로그인 뒤에 앞 세션의 늦은 응답을 쓰지 않게. +/// 로그아웃은 늘 null 을 거치므로 같은 직원이 다시 들어와도 새로 시작한다(메일의 `mailAccountProvider` 와 같은 규칙 — +/// 모듈끼리 import 하지 않아 따로 둔다) +final Provider releaseNotesAccountProvider = Provider(( + Ref ref, +) { + final SessionState? session = ref.watch(sessionProvider).value; + return session is SignedIn && session.info.restriction == null + ? session.info.email + : null; +}); diff --git a/flutter/lib/features/settings/presentation/release_notes_texts.dart b/flutter/lib/features/settings/presentation/release_notes_texts.dart new file mode 100644 index 0000000..e034ef0 --- /dev/null +++ b/flutter/lib/features/settings/presentation/release_notes_texts.dart @@ -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'; +} diff --git a/flutter/lib/features/settings/presentation/settings_texts.dart b/flutter/lib/features/settings/presentation/settings_texts.dart index ee385f0..dab4839 100644 --- a/flutter/lib/features/settings/presentation/settings_texts.dart +++ b/flutter/lib/features/settings/presentation/settings_texts.dart @@ -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 { diff --git a/flutter/lib/features/settings/presentation/widgets/release_notes_parts.dart b/flutter/lib/features/settings/presentation/widgets/release_notes_parts.dart new file mode 100644 index 0000000..bb11794 --- /dev/null +++ b/flutter/lib/features/settings/presentation/widgets/release_notes_parts.dart @@ -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()!; + 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: [ + 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()!; + return Text( + koreanCalendarDate(date), + style: style?.copyWith( + color: extra.textMuted, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ); + } +} diff --git a/flutter/lib/features/settings/settings_routes.dart b/flutter/lib/features/settings/settings_routes.dart index 7c5c9d2..37cadd3 100644 --- a/flutter/lib/features/settings/settings_routes.dart +++ b/flutter/lib/features/settings/settings_routes.dart @@ -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/`) 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) => [ @@ -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: [ + GoRoute( + path: ':noteId', + parentNavigatorKey: context.rootNavigatorKey, + builder: (BuildContext _, GoRouterState state) { + final String noteId = state.pathParameters['noteId']!; + return ReleaseNotePage( + // 글마다 새 화면 — 다른 글로 바꿔 끼우면 앞 글의 상태(받기 · 스크롤)를 이어 쓰지 않는다 + key: ValueKey(noteId), + noteId: noteId, + noteLocation: SettingsRoutes.releaseNote, + listLocation: SettingsRoutes.releaseNotes, + ); + }, + ), + ], + ), for (final ModuleSettingsSection section in context.settingsSections) GoRoute(path: section.path, builder: section.builder), ], diff --git a/flutter/test/app_test.dart b/flutter/test/app_test.dart index 44aa28b..ac0bc7b 100644 --- a/flutter/test/app_test.dart +++ b/flutter/test/app_test.dart @@ -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 [ 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(); diff --git a/flutter/test/core/utils/calendar_date_test.dart b/flutter/test/core/utils/calendar_date_test.dart new file mode 100644 index 0000000..84d8240 --- /dev/null +++ b/flutter/test/core/utils/calendar_date_test.dart @@ -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 [ + '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일'); + }); + }); +} diff --git a/flutter/test/features/settings/release_notes_page_test.dart b/flutter/test/features/settings/release_notes_page_test.dart new file mode 100644 index 0000000..eab47f2 --- /dev/null +++ b/flutter/test/features/settings/release_notes_page_test.dart @@ -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 open(WidgetTester tester, String location) async { + await h.pump(tester, location: location); + await settle(tester); + } + + Future 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 scrollTo(WidgetTester tester, Finder finder) async { + await tester.scrollUntilVisible( + finder, + 500, + scrollable: find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); + } + + Future tapVisible(WidgetTester tester, Finder finder) async { + await tester.ensureVisible(finder); + await tester.pumpAndSettle(); + await tester.tap(finder); + await settle(tester); + } + + Future 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 tops = [ + 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 [ + '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 gate = Completer(); + 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, ['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({ + 'items': '모양이 틀림', + }); + + await tester.tap(moreButton()); + await settle(tester); + + expect(h.notifier.shownCodes, ['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 gate = Completer(); + 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 []; + 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, ['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, ['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, ['2026-11-05', '2026-11-05']); + expect(findCopy('화면의 안내 글을 다듬었습니다'), findsOneWidget); + }); + + testWidgets('받는 동안 글 자리를 막대로 잡아 둔다 — 화면 낭독기는 "글 불러오는 중"', ( + WidgetTester tester, + ) async { + final Completer gate = Completer(); + 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); + }); + }); +} diff --git a/flutter/test/features/settings/release_notes_test.dart b/flutter/test/features/settings/release_notes_test.dart new file mode 100644 index 0000000..b124e42 --- /dev/null +++ b/flutter/test/features/settings/release_notes_test.dart @@ -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 { + @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 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 getNote(String id) async { + try { + return await super.getNote(id); + } finally { + notesFinished++; + } + } +} + +/// 업데이트 내역 — 모델 읽기 · 저장소 · 목록 · 글 컨트롤러 (계약 7절) +void main() { + group('모델 읽기', () { + Map summary(String id, {String? date, String? title}) => + { + 'id': id, + 'date': date ?? id.substring(0, 10), + 'title': title ?? '제목 $id', + }; + + test('목록 한 쪽 — 줄 · 다음 쪽 · 글 수', () { + final ReleaseNotesPage page = ReleaseNotesPage.tryParse({ + 'items': [summary('2026-11-18-2'), summary('2026-11-18')], + 'pageInfo': { + 'hasNext': true, + 'nextCursor': 'abc', + 'total': 41, + }, + })!; + + expect(page.items.map((ReleaseNoteSummary n) => n.id), [ + '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({ + 'items': [], + 'pageInfo': {'hasNext': false, 'nextCursor': null}, + })!; + expect(last.items, isEmpty); + expect(last.hasMore, isFalse); + expect(last.total, isNull); + + final ReleaseNotesPage noCursor = ReleaseNotesPage.tryParse( + { + 'items': [], + 'pageInfo': {'hasNext': true, 'nextCursor': ''}, + }, + )!; + expect(noCursor.hasMore, isFalse); + }); + + test('모양이 틀리면 null — 줄 하나라도(없는 날 · 빈 id · 숫자 제목)', () { + Map pageOf(List items) => { + 'items': items, + 'pageInfo': {'hasNext': false, 'nextCursor': null}, + }; + expect( + ReleaseNotesPage.tryParse( + pageOf([summary('2026-11-18'), summary('2026-02-30')]), + ), + isNull, + ); + expect( + ReleaseNotesPage.tryParse( + pageOf([summary('', date: '2026-11-18')]), + ), + isNull, + ); + expect( + ReleaseNotesPage.tryParse( + pageOf([ + { + 'id': '2026-11-18', + 'date': '2026-11-18', + 'title': 3, + }, + ]), + ), + isNull, + ); + expect( + ReleaseNotesPage.tryParse({'items': []}), + isNull, + ); + expect(ReleaseNotesPage.tryParse('목록'), isNull); + }); + + test('글 — 묶음(머리 없는 묶음 포함) · 바로 더 옛 글 · 바로 더 새 글', () { + final ReleaseNote note = ReleaseNote.tryParse({ + ...summary('2026-11-05', title: '화면의 안내 글을 다듬었습니다'), + 'sections': [ + { + 'heading': '메일 · 메일함', + 'items': ['한 줄', '두 줄'], + }, + { + 'heading': null, + 'items': ['머리 없는 줄'], + }, + ], + '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(heading: '메일 · 메일함', items: ['한 줄', '두 줄']), + ReleaseNoteSection(heading: null, items: ['머리 없는 줄']), + ]); + expect(note.previous?.id, '2026-11-04'); + expect(note.next?.id, '2026-11-18'); + }); + + test('다른 글이 없으면(null) · 칸이 아예 없으면 없는 것', () { + final ReleaseNote first = ReleaseNote.tryParse({ + ...summary('2026-11-18'), + 'sections': [], + 'previous': null, + 'next': null, + })!; + expect(first.previous, isNull); + expect(first.next, isNull); + + final ReleaseNote old = ReleaseNote.tryParse({ + ...summary('2026-11-18'), + 'sections': [], + })!; + expect(old.previous, isNull); + expect(old.next, isNull); + }); + + test('글의 모양이 틀리면 null — 묶음 · 다른 글 하나라도', () { + Map noteWith(Map extra) => + { + ...summary('2026-11-18'), + 'sections': [], + ...extra, + }; + expect( + ReleaseNote.tryParse( + noteWith({ + 'sections': [ + { + 'heading': 1, + 'items': ['줄'], + }, + ], + }), + ), + isNull, + ); + expect( + ReleaseNote.tryParse( + noteWith({ + 'sections': [ + { + 'heading': null, + 'items': ['줄', null], + }, + ], + }), + ), + isNull, + ); + expect( + ReleaseNote.tryParse( + noteWith({ + 'previous': {'id': '2026-11-04'}, + }), + ), + isNull, + ); + expect( + ReleaseNote.tryParse(noteWith({'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: [ + ...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 openList() async { + final ProviderSubscription 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 settleAfter(bool Function() condition) async { + await pumpEventsUntil(condition); + await Future.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, { + 'limit': kReleaseNotesPageSize, + }); + expect(notes.listRequests.last.queryParameters['cursor'], 'n2'); + expect( + ReleaseNotesApiPaths.releaseNote('2026/11 18'), + '/release-notes/2026%2F11%2018', + ); + expect(notes.noteRequests, ['2026-11-30']); + }, + ); + + test( + '응답 모양이 틀리면 SYS_001 — 인터셉터를 다 지난 뒤라 따로 가른다(ReleaseNotesMalformedResponse)', + () async { + notes.nextListResponse = FakeResponse.ok({'items': 1}); + final Object error = await errorOf(repository().listNotes()); + + expect(error, isA()); + 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 = [ + const FakeReleaseNote('2026-12-01', '업데이트 22'), + ...notes.notes, + ]; + + await container.read(releaseNotesListProvider.notifier).loadMore(); + + final List ids = [ + 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 late = Completer(); + int calls = 0; + notes.onList = (RequestOptions options) async { + calls++; + if (calls == 1) { + await late.future; + return FakeResponse.ok({ + 'items': [ + const FakeReleaseNote('2020-01-01', '앞 세션의 글').summaryJson(), + ], + 'pageInfo': {'hasNext': false, 'nextCursor': null}, + }); + } + return notes.listPage(options); + }; + final ProviderSubscription 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), [ + '업데이트 2', + '업데이트 1', + ]); + + late.complete(); + await settleAfter(() => recording.listsFinished == 2); + + expect(listState().status, ReleaseNotesListStatus.ready); + expect(listState().items.map((ReleaseNoteSummary n) => n.title), [ + '업데이트 2', + '업데이트 1', + ]); + }); + + test('로그인하지 않았으면(세션 없음) 묻지 않는다', () async { + container.read(_accountProvider.notifier).set(null); + final ProviderSubscription list = container.listen( + releaseNotesListProvider, + (ReleaseNotesListState? previous, ReleaseNotesListState next) {}, + ); + addTearDown(list.close); + final ProviderSubscription note = container.listen( + releaseNoteProvider('2026-11-18'), + (ReleaseNoteState? previous, ReleaseNoteState next) {}, + ); + addTearDown(note.close); + await Future.delayed(Duration.zero); + + expect(listState().status, ReleaseNotesListStatus.loading); + expect(notes.listRequests, isEmpty); + expect(notes.noteRequests, isEmpty); + }); + + test('글 — 세션이 바뀌면 다시 받고, 앞 세션의 늦은 응답(실패)은 버린다', () async { + final Completer late = Completer(); + 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 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); + }); + }); +} diff --git a/flutter/test/features/settings/settings_page_test.dart b/flutter/test/features/settings/settings_page_test.dart index f34d779..fa7b518 100644 --- a/flutter/test/features/settings/settings_page_test.dart +++ b/flutter/test/features/settings/settings_page_test.dart @@ -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, diff --git a/flutter/test/support/release_notes_fakes.dart b/flutter/test/support/release_notes_fakes.dart new file mode 100644 index 0000000..054c706 --- /dev/null +++ b/flutter/test/support/release_notes_fakes.dart @@ -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>? 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 summaryJson() => { + 'id': id, + 'date': date, + 'title': title, + }; + + /// 글 화면의 묶음 + List> sectionsJson() => + sections ?? + >[ + { + 'heading': null, + 'items': ['$title — 본문'], + }, + ]; +} + +/// 묶음 하나의 꼴 +Map releaseNoteSectionJson( + String? heading, + List items, +) => {'heading': heading, 'items': items}; + +/// 보드 `ReleaseNotes` 의 네 글(맨 위 글은 다음 배포의 첫 글 초안 — 최근 글부터) +const List kBoardReleaseNotes = [ + FakeReleaseNote( + '2026-11-18', + '메일 검색이 빨라지고 휴대폰 앱이 나왔습니다', + sections: >[ + { + 'heading': '휴대폰 앱', + 'items': [ + '안드로이드 휴대폰 앱이 나왔습니다. 웹메일의 [설정] → [휴대폰 앱]에서 받을 수 있습니다.', + '앱에서 메일을 읽고 쓰고 찾을 수 있고, 새 메일이 오면 휴대폰으로 알려 드립니다. 알림에는 새 메일이 몇 통인지만 나오고, 보낸 사람이나 제목은 나오지 않습니다.', + ], + }, + { + 'heading': '메일 검색', + 'items': ['찾은 말에는 노란 표시가 붙습니다.'], + }, + ], + ), + FakeReleaseNote( + '2026-11-05', + '화면의 안내 글을 다듬었습니다', + sections: >[ + { + 'heading': null, + 'items': [ + '단추와 안내 글을 더 알기 쉬운 말로 고쳤습니다.', + '브라우저 탭과 휴대폰 홈 화면의 아이콘을 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 notes = kBoardReleaseNotes; + + /// 받은 목록 요청 · 글 요청의 id (차례대로) + final List listRequests = []; + final List noteRequests = []; + + /// 두면 목록 · 글 요청이 이것이 끝날 때까지 기다린다(불러오는 중 · [더 보기]를 받는 중) + Completer? listGate; + Completer? 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 = [ + 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({ + 'items': >[ + for (final FakeReleaseNote note in notes.sublist(start, end)) + note.summaryJson(), + ], + 'pageInfo': { + '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({ + ...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? 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? 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); + }); + } +} -- 2.52.0 From def96e833b4addc324491b5d04b226a9d5e6ac7f Mon Sep 17 00:00:00 2001 From: ttipo Date: Tue, 10 Nov 2026 19:37:00 +0900 Subject: [PATCH 06/12] =?UTF-8?q?feat(frontend):=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=E2=80=BA=20=EC=A0=95=EB=B3=B4=20=E2=80=BA=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=EB=82=B4=EC=97=AD(=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=E2=86=92=20=EA=B8=80)=EC=9D=84=20=EB=8D=94=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 설정 메뉴 셋째 묶음 "정보", 목록은 게시판 표(줄 전체 링크 · [더 보기]), 글은 [← 목록으로] · 다음 글 · 이전 글 줄 · [목록] - 글을 보는 동안에도 메뉴 항목이 고른 상태다(aria-current 는 목록 page · 글 true) - 문서가 스크롤하는 좁은 폭에서도 글 · 목록이 맨 위부터 보이고(라우터 전역 scrollBehavior 없이), 위에 붙은 줄에 초점이 가리지 않는다 - 날짜는 달력 날짜 글이라 시간대를 거치지 않는다, /settings/info 는 목록으로 바꿔 끼운다 Co-Authored-By: Claude Opus 5.5 --- .../settings/ReleaseNoteArticle.vue | 169 ++++++++ .../components/settings/ReleaseNoteList.vue | 246 ++++++++++++ .../settings/ReleaseNoteNeighbors.vue | 114 ++++++ frontend/src/composables/useReleaseNotes.ts | 50 +++ .../composables/useSettingsPageScroll.spec.ts | 54 +++ .../src/composables/useSettingsPageScroll.ts | 23 ++ .../settings/InfoReleaseNotePage.spec.ts | 375 ++++++++++++++++++ .../pages/settings/InfoReleaseNotePage.vue | 274 +++++++++++++ .../settings/InfoReleaseNotesPage.spec.ts | 284 +++++++++++++ .../pages/settings/InfoReleaseNotesPage.vue | 133 +++++++ .../src/pages/settings/SettingsLayout.spec.ts | 74 +++- .../src/pages/settings/SettingsLayout.vue | 28 +- frontend/src/router/index.spec.ts | 14 + frontend/src/router/routes.ts | 26 +- .../src/shared/constants/release-notes.ts | 33 ++ .../src/shared/constants/settings-menu.ts | 13 +- frontend/src/shared/types/release-notes.ts | 27 ++ frontend/src/shared/utils/format.spec.ts | 42 ++ frontend/src/shared/utils/format.ts | 39 ++ .../src/shared/utils/release-notes.spec.ts | 149 +++++++ frontend/src/shared/utils/release-notes.ts | 115 ++++++ .../src/stores/release-notes.store.spec.ts | 285 +++++++++++++ frontend/src/stores/release-notes.store.ts | 159 ++++++++ frontend/src/test-utils/router.ts | 9 + 24 files changed, 2719 insertions(+), 16 deletions(-) create mode 100644 frontend/src/components/settings/ReleaseNoteArticle.vue create mode 100644 frontend/src/components/settings/ReleaseNoteList.vue create mode 100644 frontend/src/components/settings/ReleaseNoteNeighbors.vue create mode 100644 frontend/src/composables/useReleaseNotes.ts create mode 100644 frontend/src/composables/useSettingsPageScroll.spec.ts create mode 100644 frontend/src/composables/useSettingsPageScroll.ts create mode 100644 frontend/src/pages/settings/InfoReleaseNotePage.spec.ts create mode 100644 frontend/src/pages/settings/InfoReleaseNotePage.vue create mode 100644 frontend/src/pages/settings/InfoReleaseNotesPage.spec.ts create mode 100644 frontend/src/pages/settings/InfoReleaseNotesPage.vue create mode 100644 frontend/src/shared/constants/release-notes.ts create mode 100644 frontend/src/shared/types/release-notes.ts create mode 100644 frontend/src/shared/utils/release-notes.spec.ts create mode 100644 frontend/src/shared/utils/release-notes.ts create mode 100644 frontend/src/stores/release-notes.store.spec.ts create mode 100644 frontend/src/stores/release-notes.store.ts diff --git a/frontend/src/components/settings/ReleaseNoteArticle.vue b/frontend/src/components/settings/ReleaseNoteArticle.vue new file mode 100644 index 0000000..ef99912 --- /dev/null +++ b/frontend/src/components/settings/ReleaseNoteArticle.vue @@ -0,0 +1,169 @@ + + + + + diff --git a/frontend/src/components/settings/ReleaseNoteList.vue b/frontend/src/components/settings/ReleaseNoteList.vue new file mode 100644 index 0000000..21ee2de --- /dev/null +++ b/frontend/src/components/settings/ReleaseNoteList.vue @@ -0,0 +1,246 @@ + + + + + diff --git a/frontend/src/components/settings/ReleaseNoteNeighbors.vue b/frontend/src/components/settings/ReleaseNoteNeighbors.vue new file mode 100644 index 0000000..dc638c9 --- /dev/null +++ b/frontend/src/components/settings/ReleaseNoteNeighbors.vue @@ -0,0 +1,114 @@ + + + + + diff --git a/frontend/src/composables/useReleaseNotes.ts b/frontend/src/composables/useReleaseNotes.ts new file mode 100644 index 0000000..7016689 --- /dev/null +++ b/frontend/src/composables/useReleaseNotes.ts @@ -0,0 +1,50 @@ +import type { AxiosRequestConfig } from 'axios' + +import { getErrorMessage, notifyApiError, useApi, type ApiError } from './useApi' +import type { CursorPaginationQuery, PaginatedResult } from '@/shared/types/pagination' +import type { ReleaseNote, ReleaseNoteSummary } from '@/shared/types/release-notes' +import { releaseNoteOf, releaseNotePageOf } from '@/shared/utils/release-notes' + +/** 호출부가 정할 수 있는 요청 옵션 — 화면이 오류를 직접 그릴 때 silent, 취소가 필요하면 signal */ +export type ReleaseNoteRequestOptions = Pick + +const RELEASE_NOTES_PATH = '/release-notes' + +/** + * 응답은 왔는데 모양이 계약과 다르다 — 받지 못한 것(SYS_001)으로 본다. 인터셉터는 성공(200)으로 보고 알리지 않았으니 + * 알림을 끄지 않은 요청([더 보기])이면 다른 오류와 같은 길(notifyApiError)로 알린다(앱의 ReleaseNotesMalformedResponse 와 같은 결) + */ +function malformedReply(options?: ReleaseNoteRequestOptions): ApiError { + const failure: ApiError = { code: 'SYS_001', message: getErrorMessage('SYS_001') } + const silent = options?.silent + const quiet = silent === true || (typeof silent === 'object' && silent.includes(failure.code)) + if (!quiet) notifyApiError(failure) + return failure +} + +// 업데이트 내역 API Composable — 계약 7절 "업데이트 내역"(로그인 필요 · 제한된 세션은 막힌다). 읽기만 있다(올리는 API 는 없다) +// 인터셉터가 { success, data } 를 언래핑한다. 응답은 계약의 모양 그대로일 때만 읽는다(release-notes.ts — 하나라도 틀리면 SYS_001) +export function useReleaseNotes() { + const api = useApi() + + /** 목록 한 쪽 — 최근 글부터. 커서 · 개수는 다른 목록과 같다(기본 20 · 최대 100) */ + async function listReleaseNotes( + query: CursorPaginationQuery, + options?: ReleaseNoteRequestOptions, + ): Promise> { + const reply = await api.get(RELEASE_NOTES_PATH, { params: query, ...options }) + const page = releaseNotePageOf(reply) + if (page) return page + throw malformedReply(options) + } + + /** 글 하나 — 없는 글 · 모양이 틀린 id 는 404 RES_001. 경로에 넣는 id 는 늘 인코딩한다 */ + async function getReleaseNote(id: string, options?: ReleaseNoteRequestOptions): Promise { + const reply = await api.get(`${RELEASE_NOTES_PATH}/${encodeURIComponent(id)}`, options) + const note = releaseNoteOf(reply) + if (note) return note + throw malformedReply(options) + } + + return { listReleaseNotes, getReleaseNote } +} diff --git a/frontend/src/composables/useSettingsPageScroll.spec.ts b/frontend/src/composables/useSettingsPageScroll.spec.ts new file mode 100644 index 0000000..345f38e --- /dev/null +++ b/frontend/src/composables/useSettingsPageScroll.spec.ts @@ -0,0 +1,54 @@ +import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest' + +import { revealSettingsPageTop } from './useSettingsPageScroll' + +// 설정 화면의 맨 위가 보이게 — 업데이트 내역 2026-11-10 실사용 시험(목록을 내린 채 글을 열면 날짜 · 제목이 위로 지나가 있었다) +// jsdom 은 배치를 재지 못한다 — 문서 스크롤(documentElement.scrollTop) · 자리(getBoundingClientRect)를 꾸며 부른 것을 본다(창 높이는 768) + +/** 화면 자리만 꾸민 요소 */ +function placed(top: number, bottom: number): HTMLElement { + const element = document.createElement('div') + vi.spyOn(element, 'getBoundingClientRect').mockReturnValue({ top, bottom } as DOMRect) + document.body.append(element) + return element +} + +describe('revealSettingsPageTop', () => { + let scrollTo: MockInstance + + beforeEach(() => { + scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => undefined) + }) + + afterEach(() => { + vi.restoreAllMocks() + document.documentElement.scrollTop = 0 + document.body.innerHTML = '' + }) + + it('넓은 화면 — 따로 스크롤하는 글 칸을 맨 위로, 문서가 맨 위에 있으면 문서는 그대로', () => { + const main = placed(57, 900) + main.scrollTop = 640 + + revealSettingsPageTop(main, placed(150, 180)) + + expect(main.scrollTop).toBe(0) + expect(scrollTo).not.toHaveBeenCalled() + }) + + it('좁은 화면 · 문서를 맨 위로 올려도 제목이 화면 안이면 맨 위로 (메뉴가 옆에 있는 폭)', () => { + document.documentElement.scrollTop = 174 + // 문서 맨 위에서 칸은 57, 제목 아래 끝은 178 — 창 높이(768) 안 + revealSettingsPageTop(placed(57 - 174, 1200), placed(178 - 174, 178 - 174)) + + expect(scrollTo).toHaveBeenCalledWith({ top: 0, behavior: 'instant' }) + }) + + it('좁은 화면 · 위에 메뉴가 길어 맨 위로는 제목이 보이지 않으면 글 칸의 위 끝을 화면 위 끝에 (휴대폰)', () => { + document.documentElement.scrollTop = 1007 + // 문서 맨 위에서 칸은 906 — 제목 아래 끝(1027)이 창 높이(768)를 넘는다 + revealSettingsPageTop(placed(906 - 1007, 2400), placed(1027 - 1007, 1027 - 1007)) + + expect(scrollTo).toHaveBeenCalledWith({ top: 906, behavior: 'instant' }) + }) +}) diff --git a/frontend/src/composables/useSettingsPageScroll.ts b/frontend/src/composables/useSettingsPageScroll.ts new file mode 100644 index 0000000..b486c87 --- /dev/null +++ b/frontend/src/composables/useSettingsPageScroll.ts @@ -0,0 +1,23 @@ +// 설정 화면의 스크롤 — 앱 안에서 옮겨 온 화면의 맨 위(머리 · 제목)가 보이게 한다 (업데이트 내역 — 2026-11-10 실사용 시험) +// 같은 문서 안의 이동이라 앞 화면의 스크롤 자리가 남는다 — 목록을 내린 채 글을 열면 글의 날짜 · 제목이 위로 지나가 빈 글처럼 보였고, +// 글에서 목록으로 돌아오면 목록의 맨 아래 근처가 보였다. 라우터 전역 scrollBehavior 는 쓰지 않는다(메일 등 다른 화면의 뒤로 가기까지 바뀐다) — +// 쓰는 화면이 내용을 그린 뒤(받은 글 · 목록을 그린 뒤) 부른다. +// 스크롤하는 것이 폭마다 다르다 — 둘 다 맞춘다: +// - 넓은 화면(70rem 이상 — SettingsPageFrame 과 같은 기준)은 글 칸(main)이 따로 스크롤한다 → 그 칸을 맨 위로 +// - 좁은 화면은 문서가 스크롤한다 → 문서를 맨 위로 올려도 머리가 화면 안에 들면 맨 위로, 아니면(위에 메뉴가 길다 — 휴대폰 · 낮은 창) +// 글 칸의 위 끝을 화면 위 끝에 맞춘다. 문서가 맨 위에 있으면 그대로 둔다(처음 연 화면 — 다른 설정 화면과 같다) + +/** + * @param main 화면의 글 칸(main) — 넓은 화면에서 따로 스크롤하는 칸 + * @param head 꼭 보여야 할 머리(제목 h1 · 받지 못함 안내) — 문서를 맨 위로 올려도 이것이 화면 안이면 맨 위로 올린다 + */ +export function revealSettingsPageTop(main: HTMLElement, head: HTMLElement): void { + main.scrollTop = 0 + const scroller = document.scrollingElement ?? document.documentElement + const scrolled = scroller.scrollTop + if (scrolled <= 0) return + // 문서의 맨 위에서 잰 자리 — 지금 화면 자리에 스크롤한 만큼을 더한다 + const headBottom = head.getBoundingClientRect().bottom + scrolled + const mainTop = main.getBoundingClientRect().top + scrolled + window.scrollTo({ top: headBottom <= window.innerHeight ? 0 : Math.max(0, mainTop), behavior: 'instant' }) +} diff --git a/frontend/src/pages/settings/InfoReleaseNotePage.spec.ts b/frontend/src/pages/settings/InfoReleaseNotePage.spec.ts new file mode 100644 index 0000000..7e3fb45 --- /dev/null +++ b/frontend/src/pages/settings/InfoReleaseNotePage.spec.ts @@ -0,0 +1,375 @@ +import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest' +import { mount, type VueWrapper } from '@vue/test-utils' +import { createPinia, setActivePinia, type Pinia } from 'pinia' +import { createRouter, createWebHistory, type Router } from 'vue-router' + +import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated' +import type { ReleaseNote, ReleaseNoteSummary } from '@/shared/types/release-notes' +import { useAuthStore } from '@/stores/auth.store' +import { useToastStore } from '@/stores/toast.store' +import { + fail, + flushPromises, + installMockApi, + ok, + restoreApiAdapter, + type MockRequest, + type MockResult, +} from '@/test-utils/mock-api' +import { createTestRouter } from '@/test-utils/router' +import InfoReleaseNotePage from './InfoReleaseNotePage.vue' + +// 설정 › 정보 › 업데이트 내역 — 글 (디자인 원본 ReleaseNotes A2 · README "업데이트 내역 › 글") +// 맨 위 글 · 가운데 글(다른 글 줄) · 없는 글(404 RES_001 — 알림 없이 그 화면) · 받지 못함 — 어떤 상태에서도 위에 [← 목록으로] 줄 + +const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' } +const LIST_PATH = '/settings/info/release-notes' + +const TOP_SUMMARY: ReleaseNoteSummary = { + id: '2026-11-18', + date: '2026-11-18', + title: '메일 검색이 빨라지고 휴대폰 앱이 나왔습니다', +} +const MID_SUMMARY: ReleaseNoteSummary = { id: '2026-11-05', date: '2026-11-05', title: '화면의 안내 글을 다듬었습니다' } +const MENU_SUMMARY: ReleaseNoteSummary = { id: '2026-11-04', date: '2026-11-04', title: '왼쪽 메뉴를 정리했습니다' } + +/** 맨 위 글 — 다음 글 없음 · 이전 글은 가운데 글 */ +const TOP: ReleaseNote = { + ...TOP_SUMMARY, + sections: [ + { + heading: '휴대폰 앱', + items: [ + '안드로이드 휴대폰 앱이 나왔습니다. 웹메일의 [설정] → [휴대폰 앱]에서 받을 수 있습니다.', + '앱에서 메일을 읽고 쓰고 찾을 수 있고, 새 메일이 오면 휴대폰으로 알려 드립니다. 알림에는 새 메일이 몇 통인지만 나오고, 보낸 사람이나 제목은 나오지 않습니다.', + ], + }, + { heading: '메일 검색', items: ['찾은 말에는 노란 표시가 붙습니다.'] }, + ], + previous: MID_SUMMARY, + next: null, +} + +/** 가운데 글 — 머리 없는 묶음 · 다음 글(맨 위 글) · 이전 글 */ +const MID: ReleaseNote = { + ...MID_SUMMARY, + sections: [ + { + heading: null, + items: [ + '단추와 안내 글을 더 알기 쉬운 말로 고쳤습니다.', + '브라우저 탭과 휴대폰 홈 화면의 아이콘을 ARA SYSTEM 마크로 바꿨습니다.', + ], + }, + ], + previous: MENU_SUMMARY, + next: TOP_SUMMARY, +} + +const NOTES: Record = { [TOP.id]: TOP, [MID.id]: MID } + +function noteReply(request: MockRequest): MockResult { + const id = decodeURIComponent(request.url.replace('/release-notes/', '')) + const note = NOTES[id] + return note ? ok(note) : fail(404, 'RES_001') +} + +/** 응답을 손으로 보내는 요청 — 받는 동안의 화면을 본다 */ +function deferredReply() { + let resolve: (result: MockResult) => void = () => {} + const promise = new Promise((settle) => { + resolve = settle + }) + return { promise, resolve } +} + +/** + * 좁은 화면(문서가 스크롤)을 꾸민다 — 문서를 scrolled 만큼 내린 채, 문서 맨 위에서 잰 글 칸(main)의 위 끝과 머리(head)의 아래 끝. + * jsdom 은 배치를 재지 못한다 — getBoundingClientRect 를 꾸민다(창 높이는 768) + */ +function narrowLayout(scrolled: number, mainTop: number, headBottom: number, head: string) { + document.documentElement.scrollTop = scrolled + vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) { + if (this.matches('main')) return { top: mainTop - scrolled, bottom: 0 } as DOMRect + if (this.matches(head)) return { top: 0, bottom: headBottom - scrolled } as DOMRect + return { top: 0, bottom: 0 } as DOMRect + }) +} + +describe('InfoReleaseNotePage', () => { + let pinia: Pinia + let requests: MockRequest[] + let scrollTo: MockInstance + const mounted: VueWrapper[] = [] + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + useAuthStore().markAuthenticated(USER) + scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => undefined) + }) + + afterEach(() => { + for (const wrapper of mounted.splice(0)) { + if (!wrapper.vm.$.isUnmounted) wrapper.unmount() + } + vi.restoreAllMocks() + useToastStore().clear() + restoreApiAdapter() + document.documentElement.scrollTop = 0 + document.body.innerHTML = '' + }) + + async function mountAt( + id: string, + reply: (request: MockRequest) => MockResult | Promise = noteReply, + router: Router = createTestRouter(), + ) { + requests = installMockApi(reply) + await router.push(LIST_PATH) + await router.push(`${LIST_PATH}/${id}`) + await router.isReady() + const wrapper = mount(InfoReleaseNotePage, { attachTo: document.body, global: { plugins: [pinia, router] } }) + mounted.push(wrapper) + await flushPromises() + return { wrapper, router } + } + + const requestLines = () => requests.map((request) => `${request.method} ${request.url}`) + const neighborRows = (wrapper: VueWrapper) => + wrapper.findAll('nav[aria-label="다른 글"] a').map((row) => ({ + kind: row.attributes('data-release-note-neighbor'), + text: row.findAll('span, time').map((cell) => cell.text()), + href: row.attributes('href'), + })) + + describe('글 (A2)', () => { + it('맨 위 글 — 날짜 · 제목(h1) · 묶음(머리 + 점 목록), 위에 [← 목록으로] 줄 · 끝에 [목록]', async () => { + const { wrapper } = await mountAt(TOP.id) + + const main = wrapper.get('main') + const title = wrapper.get('h1') + expect(title.text()).toBe('메일 검색이 빨라지고 휴대폰 앱이 나왔습니다') + expect(main.attributes('aria-labelledby')).toBe(title.attributes('id')) + expect(main.attributes('aria-label')).toBeUndefined() + const date = wrapper.get('article time') + expect(date.text()).toBe('2026년 11월 18일') + expect(date.attributes('datetime')).toBe('2026-11-18') + + const sections = wrapper.findAll('[data-release-note-section]') + expect(sections.map((section) => section.find('h2').text())).toEqual(['휴대폰 앱', '메일 검색']) + expect(sections[0]?.findAll('li').map((item) => item.text())).toEqual(TOP.sections[0]?.items) + + const back = wrapper.get('a[data-release-note-back]') + expect(back.text()).toBe('목록으로') + expect(back.attributes('href')).toBe(LIST_PATH) + expect(back.classes()).toContain('tool-button--accent') + const list = wrapper.get('a[data-release-note-list]') + expect(list.text()).toBe('목록') + expect(list.attributes('href')).toBe(LIST_PATH) + expect(requestLines()).toEqual(['GET /release-notes/2026-11-18']) + expect(requests[0]?.config.silent).toBe(true) + }) + + it('맨 위 글이라 다음 글 줄은 없고 이전 글 줄만 — 줄 전체가 그 글로 가는 링크', async () => { + const { wrapper } = await mountAt(TOP.id) + + expect(neighborRows(wrapper)).toEqual([ + { + kind: 'older', + text: ['이전 글', '화면의 안내 글을 다듬었습니다', '2026년 11월 5일'], + href: `${LIST_PATH}/2026-11-05`, + }, + ]) + }) + + it('가운데 글 — 다음 글(더 새 글)이 위 · 이전 글(더 옛 글)이 아래, 머리 없는 묶음은 점 목록만', async () => { + const { wrapper } = await mountAt(MID.id) + + expect(neighborRows(wrapper)).toEqual([ + { + kind: 'newer', + text: ['다음 글', '메일 검색이 빨라지고 휴대폰 앱이 나왔습니다', '2026년 11월 18일'], + href: `${LIST_PATH}/2026-11-18`, + }, + { + kind: 'older', + text: ['이전 글', '왼쪽 메뉴를 정리했습니다', '2026년 11월 4일'], + href: `${LIST_PATH}/2026-11-04`, + }, + ]) + const section = wrapper.get('[data-release-note-section]') + expect(section.find('h2').exists()).toBe(false) + expect(section.findAll('li')).toHaveLength(2) + }) + + it('글이 하나뿐이면 다른 글 칸째 없다 · [목록]은 그대로', async () => { + const { wrapper } = await mountAt(TOP.id, () => ok({ ...TOP, previous: null, next: null })) + + expect(wrapper.find('nav[aria-label="다른 글"]').exists()).toBe(false) + expect(wrapper.find('a[data-release-note-list]').exists()).toBe(true) + }) + + it('다른 글 줄을 누르면 기록을 바꿔 끼우고 그 글을 받는다 — 누른 줄이 사라져 잃은 초점은 새 제목으로', async () => { + const { wrapper, router } = await mountAt(MID.id) + + const newer = wrapper.get('a[data-release-note-neighbor="newer"]') + ;(newer.element as HTMLElement).focus() + await newer.trigger('click', { button: 0 }) + await flushPromises() + + expect(router.currentRoute.value.params.id).toBe(TOP.id) + expect(requestLines()).toEqual(['GET /release-notes/2026-11-05', 'GET /release-notes/2026-11-18']) + const title = wrapper.get('h1') + expect(title.text()).toBe(TOP.title) + expect(document.activeElement).toBe(title.element) + + // 바꿔 끼웠으니 뒤로 가기는 목록으로 간다 + router.back() + await flushPromises() + expect(router.currentRoute.value.fullPath).toBe(LIST_PATH) + }) + }) + + describe('목록으로', () => { + it('[← 목록으로] · [목록] — 바로 앞 기록이 목록이 아니면 목록으로 바꿔 끼운다', async () => { + const { wrapper, router } = await mountAt(TOP.id) + + await wrapper.get('a[data-release-note-list]').trigger('click', { button: 0 }) + await flushPromises() + + expect(router.currentRoute.value.name).toBe('settings-info-release-notes') + }) + + it('바로 앞 기록이 목록이면 뒤로 가기를 쓴다 (기록을 쌓지 않는다)', async () => { + // 메모리 history 는 history.state 를 쓰지 않으므로 실제 브라우저 history 로 확인한다 + const router = createRouter({ history: createWebHistory(), routes: createTestRouter().options.routes }) + const { wrapper } = await mountAt(TOP.id, noteReply, router) + + await wrapper.get('a[data-release-note-back]').trigger('click', { button: 0 }) + await vi.waitFor(() => expect(router.currentRoute.value.fullPath).toBe(LIST_PATH)) + + // 뒤로 간 것이라 앞으로 갈 기록(보던 글)이 남아 있다 — 바꿔 끼웠다면 없다 + expect(router.options.history.state.forward).toBe(`${LIST_PATH}/${TOP.id}`) + }) + }) + + describe('상태', () => { + it('받는 동안은 글 자리를 막대로 — 위의 [← 목록으로] 줄은 그대로', async () => { + const reply = deferredReply() + const { wrapper } = await mountAt(TOP.id, () => reply.promise) + + const placeholder = wrapper.get('[data-release-note-loading]') + expect(placeholder.attributes('aria-busy')).toBe('true') + expect(placeholder.attributes('aria-label')).toBe('글 불러오는 중') + expect(wrapper.find('a[data-release-note-back]').exists()).toBe(true) + expect(wrapper.get('main').attributes('aria-label')).toBe('업데이트 내역') + + reply.resolve(ok(TOP)) + await flushPromises() + expect(wrapper.find('[data-release-note-loading]').exists()).toBe(false) + expect(wrapper.get('h1').text()).toBe(TOP.title) + }) + + it('없는 글(404 RES_001) — 알림 없이 사전 문구와 [목록으로], 위 줄은 그대로', async () => { + const { wrapper, router } = await mountAt('2026-01-01') + + const error = wrapper.get('[data-release-note-error]') + expect(error.attributes('role')).toBe('alert') + expect(error.text()).toContain(ERROR_CODE_MESSAGES.RES_001) + expect(error.text()).toContain('문의 코드 3f1c0a2e') + expect(wrapper.find('a[data-release-note-back]').exists()).toBe(true) + expect(wrapper.find('article').exists()).toBe(false) + expect(useToastStore().toasts).toEqual([]) + + const leave = error.get('button') + expect(leave.text()).toBe('목록으로') + await leave.trigger('click') + await flushPromises() + expect(router.currentRoute.value.name).toBe('settings-info-release-notes') + }) + + it('글 id 꼴이 아닌 주소는 묻지 않고 없는 글 화면', async () => { + const { wrapper } = await mountAt('latest') + + expect(requests).toEqual([]) + const error = wrapper.get('[data-release-note-error]') + expect(error.text()).toContain(ERROR_CODE_MESSAGES.RES_001) + expect(error.get('button').text()).toBe('목록으로') + }) + + it('받지 못함 — 알림 없이 사전 문구 · [다시 시도] · 문의 코드, [다시 시도]는 같은 글을 다시 받는다', async () => { + let attempt = 0 + const { wrapper } = await mountAt(TOP.id, () => { + attempt += 1 + return attempt === 1 ? fail(502, 'SYS_001') : ok(TOP) + }) + + const error = wrapper.get('[data-release-note-error]') + expect(error.text()).toContain(ERROR_CODE_MESSAGES.SYS_001) + expect(error.text()).toContain('문의 코드 3f1c0a2e') + expect(useToastStore().toasts).toEqual([]) + + await error.get('button').trigger('click') + await flushPromises() + + expect(requestLines()).toEqual(['GET /release-notes/2026-11-18', 'GET /release-notes/2026-11-18']) + expect(wrapper.get('h1').text()).toBe(TOP.title) + }) + }) + + // 2026-11-10 실사용 시험 — 목록을 내린 채 글을 열면 앞 화면의 스크롤 자리를 이어받아 날짜 · 제목이 위로 지나가 있었다 + describe('글의 맨 위가 보이게', () => { + it('받은 뒤에 맞춘다 — 문서를 맨 위로 올려도 제목이 보이면 맨 위로 (메뉴가 옆에 있는 폭 1000×600)', async () => { + narrowLayout(174, 57, 178, 'h1') + const reply = deferredReply() + await mountAt(TOP.id, () => reply.promise) + expect(scrollTo).not.toHaveBeenCalled() + + reply.resolve(ok(TOP)) + await flushPromises() + + expect(scrollTo.mock.calls).toEqual([[{ top: 0, behavior: 'instant' }]]) + }) + + it('위에 메뉴가 길어 맨 위로는 제목이 보이지 않으면 글 칸의 위 끝을 화면 위 끝에 (휴대폰 360×800)', async () => { + narrowLayout(1007, 906, 1027, 'h1') + await mountAt(TOP.id) + + expect(scrollTo.mock.calls).toEqual([[{ top: 906, behavior: 'instant' }]]) + }) + + it('없는 글이어도 그 안내가 보이게 맞춘다', async () => { + narrowLayout(1007, 906, 1240, '[data-release-note-error]') + const { wrapper } = await mountAt('2026-01-01') + + expect(wrapper.find('[data-release-note-error]').exists()).toBe(true) + expect(scrollTo.mock.calls).toEqual([[{ top: 906, behavior: 'instant' }]]) + }) + + it('문서가 맨 위에 있으면(처음 연 화면) 문서는 그대로 둔다', async () => { + await mountAt(TOP.id) + + expect(scrollTo).not.toHaveBeenCalled() + }) + + it('다른 글로 옮기면 따로 스크롤하는 글 칸(넓은 화면)도 맨 위로 — 새 제목에는 스크롤 없이 초점만', async () => { + const { wrapper } = await mountAt(MID.id) + const main = wrapper.get('main').element as HTMLElement + main.scrollTop = 700 + const focus = vi.spyOn(HTMLElement.prototype, 'focus') + + const newer = wrapper.get('a[data-release-note-neighbor="newer"]') + ;(newer.element as HTMLElement).focus() + await newer.trigger('click', { button: 0 }) + await flushPromises() + + const title = wrapper.get('h1') + expect(title.text()).toBe(TOP.title) + expect(main.scrollTop).toBe(0) + expect(document.activeElement).toBe(title.element) + const titleFocus = focus.mock.calls.filter((_, index) => focus.mock.contexts[index] === title.element) + expect(titleFocus).toEqual([[{ preventScroll: true }]]) + }) + }) +}) diff --git a/frontend/src/pages/settings/InfoReleaseNotePage.vue b/frontend/src/pages/settings/InfoReleaseNotePage.vue new file mode 100644 index 0000000..a338254 --- /dev/null +++ b/frontend/src/pages/settings/InfoReleaseNotePage.vue @@ -0,0 +1,274 @@ + + + + + diff --git a/frontend/src/pages/settings/InfoReleaseNotesPage.spec.ts b/frontend/src/pages/settings/InfoReleaseNotesPage.spec.ts new file mode 100644 index 0000000..215e49c --- /dev/null +++ b/frontend/src/pages/settings/InfoReleaseNotesPage.spec.ts @@ -0,0 +1,284 @@ +import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest' +import { mount, type VueWrapper } from '@vue/test-utils' +import { createPinia, setActivePinia, type Pinia } from 'pinia' +import type { Router } from 'vue-router' + +import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated' +import type { ReleaseNoteSummary } from '@/shared/types/release-notes' +import { useAuthStore } from '@/stores/auth.store' +import { useToastStore } from '@/stores/toast.store' +import { + fail, + flushPromises, + installMockApi, + ok, + restoreApiAdapter, + type MockRequest, + type MockResult, +} from '@/test-utils/mock-api' +import { createTestRouter } from '@/test-utils/router' +import InfoReleaseNotesPage from './InfoReleaseNotesPage.vue' + +// 설정 › 정보 › 업데이트 내역 — 목록 (디자인 원본 ReleaseNotes A1 · README "업데이트 내역 › 목록") +// 글이 있을 때(머리 줄 · 줄 전체 링크 · [더 보기]) · 불러오는 중(막대) · 받지 못함(사전 문구 · [다시 시도] · 문의 코드) · 글이 없을 때 + +const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' } + +/** 보드의 넷 — 맨 위 글은 다음 배포의 첫 글 초안 */ +const BOARD_NOTES: ReleaseNoteSummary[] = [ + { id: '2026-11-18', date: '2026-11-18', title: '메일 검색이 빨라지고 휴대폰 앱이 나왔습니다' }, + { id: '2026-11-05', date: '2026-11-05', title: '화면의 안내 글을 다듬었습니다' }, + { id: '2026-11-04', date: '2026-11-04', title: '왼쪽 메뉴를 정리했습니다' }, + { id: '2026-11-03', date: '2026-11-03', title: '2단계 인증과 앱 비밀번호를 쓸 수 있습니다' }, +] + +/** 20개를 넘는 목록 — 첫 쪽 20 · 둘째 쪽 2 */ +const MANY = Array.from({ length: 22 }, (_, index): ReleaseNoteSummary => { + const id = `2026-10-${String(31 - index).padStart(2, '0')}` + return { id, date: id, title: `${31 - index}일의 업데이트` } +}) + +const lastPage = (items: ReleaseNoteSummary[]) => ({ items, pageInfo: { hasNext: false, nextCursor: null, total: items.length } }) + +/** 응답을 손으로 보내는 요청 — 받는 동안의 화면을 본다 */ +function deferredReply() { + let resolve: (result: MockResult) => void = () => {} + const promise = new Promise((settle) => { + resolve = settle + }) + return { promise, resolve } +} + +describe('InfoReleaseNotesPage', () => { + let pinia: Pinia + let requests: MockRequest[] + let router: Router + let scrollTo: MockInstance + const mounted: VueWrapper[] = [] + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + useAuthStore().markAuthenticated(USER) + scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => undefined) + }) + + afterEach(() => { + for (const wrapper of mounted.splice(0)) { + if (!wrapper.vm.$.isUnmounted) wrapper.unmount() + } + vi.restoreAllMocks() + useToastStore().clear() + restoreApiAdapter() + document.documentElement.scrollTop = 0 + document.body.innerHTML = '' + }) + + async function mountPage(reply: (request: MockRequest) => MockResult | Promise) { + requests = installMockApi(reply) + router = createTestRouter() + await router.push('/settings/info/release-notes') + await router.isReady() + const wrapper = mount(InfoReleaseNotesPage, { attachTo: document.body, global: { plugins: [pinia, router] } }) + mounted.push(wrapper) + await flushPromises() + return wrapper + } + + const requestLines = () => requests.map((request) => `${request.method} ${request.url}`) + + describe('글이 있을 때 (A1)', () => { + it('머리 · 설명 한 줄 — 목록은 머리 줄(제목 · 날짜) 아래 최근 글부터 한 줄씩, 날짜는 "2026년 11월 18일"', async () => { + const wrapper = await mountPage(() => ok(lastPage(BOARD_NOTES))) + + expect(wrapper.get('h1').text()).toBe('업데이트 내역') + expect(wrapper.text()).toContain('웹메일과 휴대폰 앱에 새로 생기거나 바뀐 것을 알려 드립니다.') + const head = wrapper.get('.release-note-list__head') + expect(head.attributes('aria-hidden')).toBe('true') + expect(head.findAll('span').map((cell) => cell.text())).toEqual(['제목', '날짜']) + + const rows = wrapper.findAll('a[data-release-note-row]') + expect(rows.map((row) => row.get('[data-release-note-title]').text())).toEqual(BOARD_NOTES.map((note) => note.title)) + expect(rows.map((row) => row.get('[data-release-note-date]').text())).toEqual([ + '2026년 11월 18일', + '2026년 11월 5일', + '2026년 11월 4일', + '2026년 11월 3일', + ]) + expect(rows[0]?.get('time').attributes('datetime')).toBe('2026-11-18') + expect(requestLines()).toEqual(['GET /release-notes']) + expect(requests[0]?.params).toEqual({ limit: 20 }) + }) + + it('목록 이름은 화면 제목이고, 줄 전체가 그 글로 가는 링크다', async () => { + const wrapper = await mountPage(() => ok(lastPage(BOARD_NOTES))) + + const list = wrapper.get('ul[role="list"]') + expect(list.attributes('aria-labelledby')).toBe(wrapper.get('h1').attributes('id')) + const first = list.get('a[data-release-note-row]') + expect(first.attributes('href')).toBe('/settings/info/release-notes/2026-11-18') + + await first.trigger('click', { button: 0 }) + await flushPromises() + + expect(router.currentRoute.value.name).toBe('settings-info-release-note') + expect(router.currentRoute.value.params.id).toBe('2026-11-18') + }) + + it('20개뿐이면 [더 보기]가 없다', async () => { + const wrapper = await mountPage(() => ok(lastPage(MANY.slice(0, 20)))) + + expect(wrapper.findAll('a[data-release-note-row]')).toHaveLength(20) + expect(wrapper.find('[data-release-notes-more]').exists()).toBe(false) + }) + }) + + describe('[더 보기]', () => { + function pagedReply(second: () => MockResult | Promise) { + return (request: MockRequest) => + request.params.cursor === 'c20' + ? second() + : ok({ items: MANY.slice(0, 20), pageInfo: { hasNext: true, nextCursor: 'c20', total: 22 } }) + } + + it('20개씩 이어 받는다 — 받는 동안 그 단추만 바쁨, 마지막 쪽이면 단추가 사라지고 초점은 새로 붙은 첫 줄로', async () => { + const second = deferredReply() + const wrapper = await mountPage(pagedReply(() => second.promise)) + + const more = wrapper.get('button[data-release-notes-more]') + expect(more.text()).toBe('더 보기') + ;(more.element as HTMLButtonElement).focus() + await more.trigger('click') + expect(more.attributes('aria-busy')).toBe('true') + expect(more.attributes('aria-disabled')).toBe('true') + expect(wrapper.findAll('a[data-release-note-row]')).toHaveLength(20) + + second.resolve(ok(lastPage(MANY.slice(20)))) + await flushPromises() + + const rows = wrapper.findAll('a[data-release-note-row]') + expect(rows).toHaveLength(22) + expect(wrapper.find('[data-release-notes-more]').exists()).toBe(false) + expect(document.activeElement).toBe(rows[20]?.element) + expect(requests.map((request) => request.params)).toEqual([{ limit: 20 }, { cursor: 'c20', limit: 20 }]) + }) + + it('더 받지 못하면 알림으로 알리고 받은 줄 · 단추 · 초점은 그대로', async () => { + const wrapper = await mountPage(pagedReply(() => fail(503, 'SYS_001'))) + + const more = wrapper.get('button[data-release-notes-more]') + ;(more.element as HTMLButtonElement).focus() + await more.trigger('click') + await flushPromises() + + expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([ERROR_CODE_MESSAGES.SYS_001]) + expect(wrapper.findAll('a[data-release-note-row]')).toHaveLength(20) + expect(more.attributes('aria-busy')).toBeUndefined() + expect(document.activeElement).toBe(more.element) + }) + }) + + it('불러오는 중 — 머리 줄은 두고 줄 자리를 막대 넷으로, [더 보기]는 없다', async () => { + const reply = deferredReply() + const wrapper = await mountPage(() => reply.promise) + + const box = wrapper.get('[data-release-notes-loading]') + expect(box.attributes('aria-busy')).toBe('true') + expect(box.attributes('aria-label')).toBe('업데이트 내역 불러오는 중') + expect(box.find('.release-note-list__head').exists()).toBe(true) + expect(box.findAll('.release-note-list__skeleton-row')).toHaveLength(4) + expect(wrapper.find('a[data-release-note-row]').exists()).toBe(false) + expect(wrapper.find('[data-release-notes-more]').exists()).toBe(false) + + reply.resolve(ok(lastPage(BOARD_NOTES))) + await flushPromises() + expect(wrapper.find('[data-release-notes-loading]').exists()).toBe(false) + expect(wrapper.findAll('a[data-release-note-row]')).toHaveLength(4) + }) + + it('받지 못함 — 표 자리에 사전 문구 · [다시 시도] · 문의 코드, 알림은 띄우지 않는다. [다시 시도]는 다시 받는다', async () => { + let attempt = 0 + const wrapper = await mountPage(() => { + attempt += 1 + return attempt === 1 ? fail(503, 'SYS_001') : ok(lastPage(BOARD_NOTES)) + }) + + const error = wrapper.get('[data-release-notes-error]') + expect(error.attributes('role')).toBe('alert') + expect(error.text()).toContain(ERROR_CODE_MESSAGES.SYS_001) + expect(error.text()).toContain('문의 코드 3f1c0a2e') + expect(wrapper.find('.release-note-list').exists()).toBe(false) + expect(useToastStore().toasts).toEqual([]) + + await error.get('button').trigger('click') + await flushPromises() + + expect(requestLines()).toEqual(['GET /release-notes', 'GET /release-notes']) + expect(wrapper.findAll('a[data-release-note-row]')).toHaveLength(4) + }) + + it('목록이 404 여도 빠져나갈 곳이 없어 [다시 시도]다', async () => { + const wrapper = await mountPage(() => fail(404, 'RES_001')) + + const error = wrapper.get('[data-release-notes-error]') + expect(error.text()).toContain(ERROR_CODE_MESSAGES.RES_001) + expect(error.get('button').text()).toBe('다시 시도') + + await error.get('button').trigger('click') + await flushPromises() + expect(requestLines()).toEqual(['GET /release-notes', 'GET /release-notes']) + }) + + it('글이 없을 때 — 표와 [더 보기] 없이 한 줄만', async () => { + const wrapper = await mountPage(() => ok(lastPage([]))) + + const empty = wrapper.get('[data-release-notes-empty]') + expect(empty.text()).toBe('아직 알려 드릴 업데이트가 없습니다.') + expect(empty.attributes('role')).toBeUndefined() + expect(wrapper.find('.release-note-list').exists()).toBe(false) + expect(wrapper.find('[data-release-notes-more]').exists()).toBe(false) + }) + + // 2026-11-10 실사용 시험 — 좁은 폭에서 글에서 돌아오면 앞 화면의 스크롤 자리를 이어받아 목록의 맨 아래 근처가 보였다 + it('받은 뒤 목록의 맨 위가 보이게 맞춘다 — 위에 메뉴가 길면 목록 칸의 위 끝을 화면 위 끝에, [더 보기] 뒤에는 맞추지 않는다', async () => { + // 휴대폰 360×800 — 문서를 1007 내린 채, 문서 맨 위에서 목록 칸은 906 · 제목 아래 끝은 931 (창 높이 768 을 넘는다) + document.documentElement.scrollTop = 1007 + vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) { + if (this.matches('main')) return { top: 906 - 1007, bottom: 0 } as DOMRect + if (this.matches('h1')) return { top: 0, bottom: 931 - 1007 } as DOMRect + return { top: 0, bottom: 0 } as DOMRect + }) + const second = deferredReply() + const wrapper = await mountPage((request) => + request.params.cursor === 'c20' + ? second.promise + : ok({ items: MANY.slice(0, 20), pageInfo: { hasNext: true, nextCursor: 'c20', total: 22 } }), + ) + + expect(scrollTo.mock.calls).toEqual([[{ top: 906, behavior: 'instant' }]]) + + await wrapper.get('button[data-release-notes-more]').trigger('click') + second.resolve(ok(lastPage(MANY.slice(20)))) + await flushPromises() + + expect(wrapper.findAll('a[data-release-note-row]')).toHaveLength(22) + expect(scrollTo).toHaveBeenCalledTimes(1) + }) + + it('문서가 맨 위에 있으면(처음 연 화면) 문서는 그대로 두고, 따로 스크롤하는 칸(넓은 화면)은 [다시 시도]로 받은 뒤에도 맨 위로', async () => { + let attempt = 0 + const wrapper = await mountPage(() => { + attempt += 1 + return attempt === 1 ? fail(503, 'SYS_001') : ok(lastPage(BOARD_NOTES)) + }) + const main = wrapper.get('main').element as HTMLElement + main.scrollTop = 300 + + await wrapper.get('[data-release-notes-error] button').trigger('click') + await flushPromises() + + expect(wrapper.findAll('a[data-release-note-row]')).toHaveLength(4) + expect(main.scrollTop).toBe(0) + expect(scrollTo).not.toHaveBeenCalled() + }) +}) diff --git a/frontend/src/pages/settings/InfoReleaseNotesPage.vue b/frontend/src/pages/settings/InfoReleaseNotesPage.vue new file mode 100644 index 0000000..ba1aa8e --- /dev/null +++ b/frontend/src/pages/settings/InfoReleaseNotesPage.vue @@ -0,0 +1,133 @@ + + + + + diff --git a/frontend/src/pages/settings/SettingsLayout.spec.ts b/frontend/src/pages/settings/SettingsLayout.spec.ts index fa5f355..b230076 100644 --- a/frontend/src/pages/settings/SettingsLayout.spec.ts +++ b/frontend/src/pages/settings/SettingsLayout.spec.ts @@ -6,12 +6,14 @@ import { createMemoryHistory, createRouter } from 'vue-router' import { routes } from '@/router/routes' import { useAuthStore } from '@/stores/auth.store' +import { flushPromises } from '@/test-utils/mock-api' import { createTestRouter } from '@/test-utils/router' import SettingsLayout from './SettingsLayout.vue' // 설정 메뉴 — 계정 › 비밀번호 · 2단계 인증 · 앱 비밀번호 · 메일 프로그램 연결 · 휴대폰 앱 · 로그인 기록 / 메일 › 서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 · 차단한 주소 · 대용량 첨부 · 알림 +// / 정보 › 업데이트 내역 // (디자인 README "4단계 › 설정 메뉴" · "5단계" · "6단계" · "9단계 › 설정 · 보내기 · 읽기 · 차단한 주소" · "11단계 › 설정 › 메일 › 대용량 첨부" · "12단계 — 계정 보안" · -// "13단계" · "휴대폰 앱 받기") +// "13단계" · "휴대폰 앱 받기" · "업데이트 내역") describe('SettingsLayout', () => { let pinia: Pinia @@ -36,13 +38,17 @@ describe('SettingsLayout', () => { return { wrapper, router } } - it('묶음 둘 — 계정(비밀번호 · 2단계 인증 · 앱 비밀번호 · 메일 프로그램 연결 · 휴대폰 앱 · 로그인 기록) · 메일(서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 · 차단한 주소 · 대용량 첨부 · 알림), 묶음 이름이 목록의 이름이다', async () => { + it('묶음 셋 — 계정(비밀번호 · 2단계 인증 · 앱 비밀번호 · 메일 프로그램 연결 · 휴대폰 앱 · 로그인 기록) · 메일(서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 · 차단한 주소 · 대용량 첨부 · 알림) · 정보(업데이트 내역), 묶음 이름이 목록의 이름이다', async () => { const { wrapper } = await mountAt('/settings/account/password') const menu = wrapper.get('nav[aria-label="설정 메뉴"]') - expect(menu.findAll('.settings-menu__group').map((group) => group.text())).toEqual(['계정', '메일']) + expect(menu.findAll('.settings-menu__group').map((group) => group.text())).toEqual(['계정', '메일', '정보']) const lists = menu.findAll('ul') - expect(lists.map((list) => list.attributes('aria-labelledby'))).toEqual(['settings-menu-account', 'settings-menu-mail']) + expect(lists.map((list) => list.attributes('aria-labelledby'))).toEqual([ + 'settings-menu-account', + 'settings-menu-mail', + 'settings-menu-info', + ]) expect(lists.map((list) => list.findAll('a').map((link) => [link.text(), link.attributes('href')]))).toEqual([ [ ['비밀번호', '/settings/account/password'], @@ -61,9 +67,69 @@ describe('SettingsLayout', () => { ['대용량 첨부', '/settings/mail/large-files'], ['알림', '/settings/mail/notify'], ], + [['업데이트 내역', '/settings/info/release-notes']], ]) }) + it('설정 › 정보 › 업데이트 내역 — 목록 /settings/info/release-notes · 글 /settings/info/release-notes/<글 id>, 로그인이 필요하고 탭 제목은 둘 다 "업데이트 내역" (앱의 라우트 표 그대로)', async () => { + const router = createRouter({ history: createMemoryHistory(), routes }) + + await router.push('/settings/info/release-notes') + expect(router.currentRoute.value.name).toBe('settings-info-release-notes') + expect(router.currentRoute.value.meta).toMatchObject({ title: '업데이트 내역', requiresAuth: true }) + + await router.push('/settings/info/release-notes/2026-11-18-2') + expect(router.currentRoute.value.name).toBe('settings-info-release-note') + expect(router.currentRoute.value.params.id).toBe('2026-11-18-2') + expect(router.currentRoute.value.meta).toMatchObject({ title: '업데이트 내역', requiresAuth: true }) + + expect(router.resolve({ name: 'settings-info-release-notes' }).fullPath).toBe('/settings/info/release-notes') + expect(router.resolve({ name: 'settings-info-release-note', params: { id: '2026-11-18' } }).fullPath).toBe( + '/settings/info/release-notes/2026-11-18', + ) + }) + + it('/settings/info 는 업데이트 내역 목록으로 바꿔 끼운다 — 글 주소의 ".." 를 브라우저가 다듬은 주소, 다른 묶음 주소는 그대로', async () => { + const router = createRouter({ history: createMemoryHistory(), routes }) + + for (const path of ['/settings/info', '/settings/info/']) { + await router.push(path) + expect(router.currentRoute.value.name, path).toBe('settings-info-release-notes') + expect(router.currentRoute.value.fullPath, path).toBe('/settings/info/release-notes') + } + expect(router.resolve('/settings/account').name).toBe('not-found') + expect(router.resolve('/settings/mail').name).toBe('not-found') + }) + + it('업데이트 내역 메뉴는 문서 아이콘(file-text) — 목록이면 aria-current="page", 글을 보는 동안에는 고른 모양 그대로 aria-current="true"', async () => { + for (const [path, current] of [ + ['/settings/info/release-notes', 'page'], + ['/settings/info/release-notes/2026-11-18', 'true'], + ] as const) { + const { wrapper } = await mountAt(path) + const marked = wrapper.get('nav[aria-label="설정 메뉴"]').findAll('a[aria-current]') + + expect(marked.map((link) => [link.text(), link.attributes('aria-current')]), path).toEqual([['업데이트 내역', current]]) + expect(marked[0]?.classes()).toContain('is-active') + expect(marked[0]?.find('path').attributes('d')).toBe('M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z') + expect(marked[0]?.find('polyline').attributes('points')).toBe('14 2 14 8 20 8') + wrapper.unmount() + } + }) + + it('메뉴 항목을 누르면 그 화면으로 간다 — 글을 보다가 업데이트 내역을 누르면 목록', async () => { + const { wrapper, router } = await mountAt('/settings/info/release-notes/2026-11-18') + const item = wrapper + .get('nav[aria-label="설정 메뉴"]') + .findAll('a') + .find((link) => link.text() === '업데이트 내역') + + await item?.trigger('click', { button: 0 }) + await flushPromises() + + expect(router.currentRoute.value.fullPath).toBe('/settings/info/release-notes') + }) + it('지금 화면의 항목만 현재로 표시한다', async () => { const { wrapper } = await mountAt('/settings/mail/vacation') const current = wrapper.get('nav[aria-label="설정 메뉴"]').findAll('a[aria-current="page"]') diff --git a/frontend/src/pages/settings/SettingsLayout.vue b/frontend/src/pages/settings/SettingsLayout.vue index eefdbec..f302ffc 100644 --- a/frontend/src/pages/settings/SettingsLayout.vue +++ b/frontend/src/pages/settings/SettingsLayout.vue @@ -1,7 +1,10 @@