diff --git a/e2e/README.md b/e2e/README.md index 97c9870..13491bc 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -14,6 +14,17 @@ npm run e2e:ui # 에뮬레이터를 켜고 Playwright UI 창을 연 케이스를 골라 돌리기, ID·태그로 거르기, 반복 실행은 UI 창 안에서 한다. +### 클릭으로 테스트 만들기 + +```bash +npm run e2e:record +``` + +새 테스트 사용자로 로그인하고 예시 데이터(카테고리·할 일·루틴)를 넣은 홈 화면에서 멈춘다. +함께 뜨는 Playwright Inspector 에서 **Record** 를 누르고 화면을 조작하면 그 조작이 코드로 쌓인다. +코드를 복사해 케이스 설명(`cases/*.md` 형식)과 함께 넘기거나 spec 에 붙인다. 창을 닫으면 끝난다. +녹화한 코드는 `getByRole` 같은 셀렉터를 고르지만 확인(`expect`)은 넣지 않으므로, spec 으로 옮길 때 기대 결과를 더한다. + ## CI `.github/workflows/test.yml` 이 PR 과 main 푸시마다 `npm run check` 와 `npm run e2e` 를 돌린다. @@ -78,7 +89,7 @@ test("TC-ROUTINE-001 ...", { tag: "@regression" }, async ({ page, signIn }) => { 역할도 이름도 없는 요소(끌기 미리보기 같은 그림)에만 `data-testid` 를 단다. - `signIn()` 은 로그인 전용 화면을 새로 열어 로그인한다. 같은 탭에서 계정을 바꾸는 흐름처럼 새로고침 없이 이어져야 하면 `signIn({ inPlace: true })` 를 쓴다. - 헤더 메뉴로 화면을 옮긴 직후에는 새 화면이 뜬 것을 먼저 확인한다. 다음 화면 코드를 받는 동안 이전 화면이 그대로 남아 있어, 같은 이름의 요소를 잘못 잡는다. -- 고정 대기(`waitForTimeout`)는 쓰지 않는다. `expect(...).toBeVisible()` 처럼 상태를 기다린다. 예외는 `drag.ts` 의 누르고 있기뿐이다. +- 고정 대기(`waitForTimeout`)는 쓰지 않는다. `expect(...).toBeVisible()` 처럼 상태를 기다린다. - 화면은 저장을 기다리지 않고 먼저 바뀐다. 새로고침이나 다른 화면에서 저장을 확인하려면 먼저 `waitForSaved(page)` 를 부른다. - 일부러 실패를 일으켜 콘솔 에러가 나는 테스트는 `test.use({ expectedError: /.../ })` 로 그 에러만 허용한다. - 회귀 케이스를 만들면 고친 코드를 잠시 되돌려 테스트가 실제로 실패하는지 확인한다. 통과만 확인한 테스트는 아무것도 지켜 주지 않을 수 있다. diff --git a/e2e/TODO.md b/e2e/TODO.md index 643b8a1..4848eaa 100644 --- a/e2e/TODO.md +++ b/e2e/TODO.md @@ -1,28 +1,8 @@ -# 케이스 구현 전에 필요한 것 +# 남은 확인 거리 -케이스 후보를 뽑으며 찾은, 테스트를 막는 지점이다. 해결하면 지운다. - -## 마크업에 이름 붙이기 - -역할이나 이름이 없어 테스트가 사람처럼 찾을 수 없는 요소. 스크린 리더 사용자도 같은 이유로 쓰기 어렵다. -P1 케이스가 거치는 요소는 이름을 붙였다. 남은 것은 P2·P3 케이스용이다. - -| 위치 | 문제 | 제안 | -| --- | --- | --- | -| src/pages/HomePage/components/TitleSection.tsx:56, 59 | ◀▶ 가 `div` onClick | `button` + `aria-label="이전"`/`"다음"` | -| 같은 파일 | 섹션 제목이 heading 이 아니라 "오늘"·"새로고침" 을 섹션별로 좁혀 잡을 수 없다 | `section aria-labelledby` 로 감싸기 | -| src/pages/HomePage/components/TaskReport/modals/TaskSettingModal.tsx:101 | 선택 버튼이 빈 `button` | `role="radio"` + `aria-checked` | -| src/pages/RoutinePage/.../EndDateField.tsx:31-35 | "추가"/"삭제" 가 다른 버튼과 이름이 겹침 | `aria-label="종료 날짜 추가"` | -| src/shared/ui/notices/components/NoticeList.tsx:27 | 공지 항목이 클릭만 되는 `div` | `button` | - -## seed 확장 - -- `category` 의 `status: "ENDED"` 와 `endedAt` -- `routine` 의 `endDate`, 여러 구간의 `scheduleHistory` -- `task` 의 `time` -- `monthlyStats`: 마이 화면 달성률과 월간 통계 회귀 케이스용 -- `notice`: 최상위 `notices` 컬렉션 (모든 사용자가 함께 본다) +테스트를 만들며 찾았지만 아직 결론을 내지 못한 것이다. 해결하면 지운다. ## 확인이 필요한 앱 동작 - **다시 받아 온 집계가 진행 중인 체크 표시를 덮는다**: 체크하면 캘린더 숫자를 먼저 고쳐 두는데, 그사이 다른 곳(첫 문서 생성 뒤 재계산 등)이 월간 통계를 다시 받아 오면 고쳐 둔 값이 덮여 새로고침 전까지 옛 숫자가 보인다. 저장된 값은 맞다. 부하가 클 때 TC-HOME-004 에서 한 번 관찰했다. 체크가 끝났을 때 그사이 통계를 다시 받아 왔으면 한 번 더 받아 오는 식으로 막을 수 있으나, 재현을 고정하는 테스트를 먼저 만들어야 한다. + - 재현 시도: CPU 를 6배·10배로 늦추고 같은 조작(문서 생성 확인 → 할 일 추가 → 루틴 체크)을 56번 돌렸으나 끝까지 진행된 실행에서는 한 번도 나오지 않았다. 다시 관찰되면 그 실행의 trace(월간 통계 요청 순서)를 남겨 원인을 좁힌다. diff --git a/e2e/cases/category.md b/e2e/cases/category.md index 5c4bf07..ce5e1f0 100644 --- a/e2e/cases/category.md +++ b/e2e/cases/category.md @@ -10,7 +10,7 @@ - 근거: src/shared/api/category/crud.ts:48-69 ### TC-CATEGORY-002 가운데를 종료한 뒤 새로 만든 카테고리는 맨 뒤에 붙는다 -- 우선순위: P2 / 태그: @regression / 상태: 제안 +- 우선순위: P2 / 태그: @regression / 상태: 구현됨 - 전제: A(0), B(1), C(2) - 절차: B 종료(확인 창에서 "종료") → D 생성 - 기대: 진행중 목록이 A, C, D @@ -40,46 +40,46 @@ - 근거: CategoryModal.tsx:188, 198-215 ### TC-CATEGORY-006 복구하면 진행중 목록 맨 뒤로 돌아온다 -- 우선순위: P2 / 태그: @feature / 상태: 제안 +- 우선순위: P2 / 태그: @feature / 상태: 구현됨 - 전제: 진행중 A, B. 종료된 C (seed 확장 필요) - 기대: A, B, C. 종료 섹션이 비면 섹션째 사라진다 - 근거: crud.ts:119-134 ### TC-CATEGORY-007 이름이 비었거나 공백뿐이면 저장되지 않는다 -- 우선순위: P2 / 태그: @feature / 상태: 제안 +- 우선순위: P2 / 태그: @feature / 상태: 구현됨 - 기대: 공백만이면 "저장" 비활성, Enter 는 "카테고리 이름을 입력해 주세요." 21자는 20자까지만 - 근거: CategoryModal.tsx:26, 32-38, 101-106 ### TC-CATEGORY-008 저장에 실패하면 토스트가 뜨고 모달이 남는다 -- 우선순위: P2 / 태그: @feature / 상태: 제안 +- 우선순위: P2 / 태그: @feature / 상태: 구현됨 - 전제: 쓰기 실패 - 절차: 생성 / 수정 / 종료(확인 창에서 "종료") - 기대: 각각의 실패 토스트, 모달 유지, 목록 그대로 - 근거: CategoryModal.tsx:85-99 ### TC-CATEGORY-009 순서 저장에 실패하면 원래 순서로 돌아간다 -- 우선순위: P2 / 태그: @feature / 상태: 제안 +- 우선순위: P2 / 태그: @feature / 상태: 구현됨 - 기대: "순서 저장에 실패했습니다…" 토스트, A, B, C 로 원복 - 근거: CategorySection.tsx:94-98 ### TC-CATEGORY-010 저장을 연달아 눌러도 하나만 만들어진다 -- 우선순위: P2 / 태그: @feature / 상태: 제안 +- 우선순위: P2 / 태그: @feature / 상태: 구현됨 - 전제: 쓰기 응답을 1초 늦춘다 - 기대: "저장 중...", 같은 이름이 1개만 - 근거: CategoryModal.tsx:67-68, 89 ### TC-CATEGORY-011 수정 중에 닫으려 하면 확인을 묻는다 -- 우선순위: P3 / 태그: @feature / 상태: 제안 -- 기대: "수정 중인 내용이 있습니다". 공백만 덧붙였으면 묻지 않는다 +- 우선순위: P3 / 태그: @feature / 상태: 구현됨 +- 기대: "수정 중인 내용이 있습니다". 버리면 상세로 돌아가 이름이 그대로다. 공백만 덧붙였으면 묻지 않는다. "취소" 버튼은 묻지 않고 돌아간다 - 근거: 3100b2c, CategoryModal.tsx:73-82 ### TC-CATEGORY-012 첫 사용자에게 빈 화면 안내가 보인다 -- 우선순위: P3 / 태그: @feature / 상태: 제안 +- 우선순위: P3 / 태그: @feature / 상태: 구현됨 - 기대: "진행중인 카테고리가 없습니다." 와 "카테고리 추가". "종료된 카테고리" 제목은 없다 - 근거: f801b9e, CategorySection.tsx:142-161 ### TC-CATEGORY-013 목록을 읽지 못하면 다시 시도할 수 있다 -- 우선순위: P3 / 태그: @feature / 상태: 제안 +- 우선순위: P3 / 태그: @feature / 상태: 구현됨 - 전제: 첫 조회만 실패 - 기대: "카테고리를 불러오지 못했습니다." → "다시 시도" 로 목록 - 근거: CategorySection.tsx:130-139 diff --git a/e2e/cases/common.md b/e2e/cases/common.md index 3b84b5b..6568cc7 100644 --- a/e2e/cases/common.md +++ b/e2e/cases/common.md @@ -7,18 +7,19 @@ - 근거: src/shared/stores/authStore.ts:95-101, firestore.rules:83-87, de153af ### TC-COMMON-002 로그인한 사용자가 / 나 /login 에 가면 /home 으로 간다 -- 우선순위: P2 / 태그: @feature / 상태: 제안 +- 우선순위: P2 / 태그: @feature / 상태: 구현됨 - 기대: 두 경우 모두 /home, "달성 현황" 이 보인다 - 근거: src/pages/LandingPage/LandingPage.tsx:22, src/pages/LoginPage/LoginPage.tsx:19 +- 발견: 모바일에서 소개 화면이 자리 잡기 전이나 떠나는 순간 애니메이션 시점이 0/0 = NaN 이 되어 콘솔 에러가 나던 문제를 찾아 고쳤다(useFilmEngine.ts) ### TC-COMMON-003 공지는 고정 공지가 먼저, 상세에서 이전·다음으로 오간다 -- 우선순위: P2 / 태그: @feature / 상태: 제안 +- 우선순위: P2 / 태그: @feature / 상태: 구현됨 - 전제: 고정 1건, 일반 2건 (seed 확장 필요. 공지는 모든 사용자가 같이 보므로 제목에 고유값을 넣는다) - 절차: 헤더 "공지" → 두 번째 공지 → "다음 공지" → 목록으로 → "닫기" - 기대: 고정 공지 → 최신순. 상세 제목이 바뀌고 모달이 닫힌다 - 근거: src/shared/api/notice/queries.ts:14, src/shared/ui/notices/index.tsx:23-30 ### TC-COMMON-004 푸터에서 약관으로 가고, 인스타그램은 새 창으로 열린다 -- 우선순위: P3 / 태그: @feature / 상태: 제안 +- 우선순위: P3 / 태그: @feature / 상태: 구현됨 - 기대: /terms 로 이동. "checky 인스타그램 (새 창)" 은 target=_blank - 근거: src/shared/ui/Footer.tsx:21-32, 10d7c86 diff --git a/e2e/cases/home.md b/e2e/cases/home.md index 1e192f4..5dec77d 100644 --- a/e2e/cases/home.md +++ b/e2e/cases/home.md @@ -44,60 +44,60 @@ - 근거: src/pages/HomePage/components/TaskReport/hooks/useTaskModalHandlers.ts:113, :175, TaskModal.tsx (삭제 확인) ### TC-HOME-007 "이동" 으로 완료한 할 일을 다른 날로 옮긴다 -- 우선순위: P2 / 태그: @feature / 상태: 제안 +- 우선순위: P2 / 태그: @feature / 상태: 구현됨 - 전제: 10-21 에 완료한 할 일. today 2026-10-21 - 절차: ⋯ → "이동" → 10-23 → 확인 → 캘린더 23일 클릭 - 기대: 21일에서 사라지고 23일에 완료 상태로 보인다. 캘린더 숫자가 맞게 바뀐다 - 근거: src/pages/HomePage/components/TaskReport/hooks/useTaskDateMove.ts:42-96 ### TC-HOME-008 리스트 메뉴 "미완료 할 일을 오늘 하기" -- 우선순위: P2 / 태그: @feature / 상태: 제안 +- 우선순위: P2 / 태그: @feature / 상태: 구현됨 - 전제: 10-20 에 미완료 1개, 완료 1개. today 2026-10-21 -- 절차: ◀ 로 20일 → 리스트 메뉴 → 항목 선택 → "완료" → "오늘" +- 절차: ◀ 로 20일 → 리스트 메뉴 → "미완료 할 일을 오늘 하기" → "완료" → 할 일 목록 "오늘" - 기대: 20일엔 완료만 남고 21일에 미완료 1개. 오늘 날짜에서 하면 "이미 오늘 목록입니다." - 근거: src/pages/HomePage/components/TaskReport/sections/TaskSetting.tsx:86-130 ### TC-HOME-009 "모든 할 일 삭제" 확인과 빈 대상 안내 -- 우선순위: P2 / 태그: @feature / 상태: 제안 +- 우선순위: P2 / 태그: @feature / 상태: 구현됨 - 전제: (a) 할 일 2개 (b) 할 일 없음 - 절차: 리스트 메뉴 → "모든 할 일 삭제" → "완료" → 확인에서 "삭제" / "취소" - 기대: (a) 삭제하면 목록이 비고, 취소하면 그대로 (b) "삭제할 할 일이 없습니다." - 근거: TaskSetting.tsx:93, :243 ### TC-HOME-010 한 주 보기에서 끌어 옮기면 개수와 하루 보기에 반영된다 -- 우선순위: P2 / 태그: @feature / 상태: 제안 +- 우선순위: P2 / 태그: @feature / 상태: 구현됨 - 전제: 10-21 에 미완료 할 일 1개. today 2026-10-21 - 절차: "한 주" → 할 일을 "10월 23일" 로 끈다 → "10월 23일 하루 보기" - 기대: 21일 칸 "할 일 0", 23일 칸 "할 일 1", 하루 보기 23일에 보인다 - 근거: src/pages/HomePage/components/TaskReport/hooks/useTaskWeek.ts:167-215 (두 날짜 캐시를 손으로 고침) ### TC-HOME-011 끌어 옮기기에 실패하면 원래 날로 돌아간다 -- 우선순위: P2 / 태그: @feature / 상태: 제안 +- 우선순위: P2 / 태그: @feature / 상태: 구현됨 - 전제: TC-HOME-010 과 같고 쓰기를 실패시킨다 - 기대: "할 일 이동에 실패했습니다…" 토스트, 21일 칸으로 돌아간다 - 근거: useTaskWeek.ts:202-208 ### TC-HOME-012 루틴 표 체크가 합계·캘린더에 반영된다 -- 우선순위: P2 / 태그: @feature / 상태: 제안 +- 우선순위: P2 / 태그: @feature / 상태: 구현됨 - 전제: 10-01 부터 매일 루틴 1개. today 2026-10-21 - 절차: 21일 칸 체크 → 새로고침 - 기대: 행 합계 "1 / 7", 합계 행 21일 "1/1", 22일 이후 "–", 캘린더 21일 완료 색. 새로고침해도 유지 - 근거: src/pages/HomePage/components/RoutineReport/hooks/useRoutineToggle.ts:43-123 ### TC-HOME-013 캘린더 날짜를 누르면 모든 섹션이 그 날을 따라간다 -- 우선순위: P2 / 태그: @feature / 상태: 제안 +- 우선순위: P2 / 태그: @feature / 상태: 구현됨 - 전제: 10-14 에 할 일 "지난주 일". today 2026-10-21 - 절차: 캘린더 14일 → 할 일 목록 "오늘" -- 기대: 할 일·루틴 주·달성 현황이 14일 기준으로 바뀌고, "오늘" 을 누르면 21일로 돌아온다 +- 기대: 할 일·루틴 주(10/11~17, 일요일 시작)·달성 현황이 14일 기준으로 바뀌고, "오늘" 을 누르면 21일로 돌아온다 - 근거: src/pages/HomePage/components/MonthlyReport/components/CalendarCell.tsx:49 ### TC-HOME-014 새 사용자의 빈 상태 -- 우선순위: P3 / 태그: @feature / 상태: 제안 +- 우선순위: P3 / 태그: @feature / 상태: 구현됨 - 기대: "아직 카테고리가 없어요." 와 "카테고리 만들기" 링크(→ /category), 달성 현황 빈 안내 - 근거: src/pages/HomePage/components/TaskReport/sections/TaskList.tsx:35 ### TC-HOME-015 루틴 목록을 불러오지 못하면 다시 시도할 수 있다 -- 우선순위: P3 / 태그: @feature / 상태: 제안 +- 우선순위: P3 / 태그: @feature / 상태: 구현됨 - 전제: 루틴 조회를 첫 번째만 실패시킨다 - 기대: "루틴 목록을 불러오지 못했습니다." → "다시 시도" 로 표가 보인다 - 근거: src/pages/HomePage/components/RoutineReport/index.tsx:60-66 @@ -110,3 +110,11 @@ - 발견: 문서가 없을 때의 집계 반영은 건너뛰고 곧이어 저장되는 문서는 조작 전에 센 값이라, 조작이 영구히 빠졌다. 건너뛴 조작을 세어 두고, 첫 문서를 만든 쪽이 그 수가 바뀌었으면 원본에서 다시 세도록 고쳤다 - 근거: src/shared/hooks/calendar/monthlyStats/useMonthlyData.ts (첫 문서 저장), src/shared/api/monthlyStats/queries.ts (patchMonthlyStatsByDayDeltas 의 missing-doc) - 재현: 첫 monthlyStats 커밋을 page.route 로 2초 붙잡고 그 사이에 할 일을 추가한다 + +### TC-HOME-017 리스트 메뉴로 미완료 할 일을 다른 날(다음 날)로 옮긴다 +- 우선순위: P2 / 태그: @feature / 상태: 구현됨 +- 전제: 10-21 에 미완료 1개, 완료 1개. today 2026-10-21 +- 절차: 리스트 메뉴 → "미완료 할 일을 다른 날 하기" → "완료" → 날짜 선택(기본값 다음 날) → "이동" +- 기대: 21일에는 완료한 것만 남고, 22일에 미완료가 보인다. 캘린더 21일 남은 일 0개, 22일 1개. 옮길 것이 없으면 "옮길 미완료 할 일이 없습니다." +- 근거: src/pages/HomePage/components/TaskReport/sections/TaskSetting.tsx ("after" 동작) +- 출처: 녹화 모드로 직접 추가 (`npm run e2e:record`) diff --git a/e2e/cases/my.md b/e2e/cases/my.md index eb0ebe5..ce6d98f 100644 --- a/e2e/cases/my.md +++ b/e2e/cases/my.md @@ -30,17 +30,17 @@ - 메모: 새로고침하면 메모리 캐시가 사라져 버그가 가려지므로 같은 탭에서 계정을 바꾼다. 캐시 키가 사용자 ID 를 품고 있어, 로그아웃 때 캐시를 비우는 코드만 빼서는 이 케이스가 실패하지 않는다. 두 장치 중 하나라도 남아 있는지를 지킨다 ### TC-MY-005 다크 테마를 고르면 바로 바뀌고 새로고침해도 유지된다 -- 우선순위: P2 / 태그: @feature / 상태: 제안 +- 우선순위: P2 / 태그: @feature / 상태: 구현됨 - 기대: "다크" 가 눌린 상태, html 에 dark 클래스. 새로고침 후에도 같다 - 근거: src/pages/MyPage/components/ThemeSection.tsx:31-37, 40d07b7 ### TC-MY-006 프로필과 최근 6개월 달성률을 보여 준다 -- 우선순위: P2 / 태그: @feature / 상태: 제안 +- 우선순위: P2 / 태그: @feature / 상태: 구현됨 - 전제: today 2026-10-21. 9월 월간 통계(10건 중 7건 완료) (seed 확장 필요) - 기대: 이름 "E2E 사용자", 5월~10월, 9월 70%, 기록 없는 달은 "기록 없음" - 근거: src/pages/MyPage/components/MonthlyHistorySection.tsx:89-104, 1fce43f, 9a95573 ### TC-MY-007 탈퇴 확인에서 취소하면 아무것도 바뀌지 않는다 -- 우선순위: P3 / 태그: @feature / 상태: 제안 +- 우선순위: P3 / 태그: @feature / 상태: 구현됨 - 기대: 모달이 닫히고 팝업이 열리지 않는다 - 근거: src/pages/MyPage/MyPage.tsx:138-147 diff --git a/e2e/cases/record.md b/e2e/cases/record.md index a09c8b5..3de355b 100644 --- a/e2e/cases/record.md +++ b/e2e/cases/record.md @@ -39,51 +39,51 @@ - 근거: src/pages/RecordPage/hooks/useRecordData.ts:42-45 ### TC-RECORD-006 이전·다음으로 오가며 기간이 바뀌고, 미래 기간은 세지 않는다 -- 우선순위: P2 / 태그: @feature / 상태: 제안 +- 우선순위: P2 / 태그: @feature / 상태: 구현됨 - 절차: 월간 "이전 달" → "다음 달" → "다음 달" → "이번 달" - 기대: 2026년 9월 → 10월 → 11월 "아직 오지 않은 기간이에요." → 10월 - 근거: src/pages/RecordPage/index.tsx:66-69, 111-114 ### TC-RECORD-007 연도가 바뀌는 달로 이동한다 -- 우선순위: P2 / 태그: @feature / 상태: 제안 +- 우선순위: P2 / 태그: @feature / 상태: 구현됨 - 전제: today 2027-01-05. 2026-12-31 에 완료한 할 일 - 기대: "이전 달" 은 2026년 12월, 해낸 할 일 1 / 1. 주간 "이번 주" 는 1월 3일 ~ 1월 9일 - 근거: src/shared/utils/dateNavigation.ts:11-16 ### TC-RECORD-008 주간·월간을 바꿔도 고른 날짜가 유지된다 -- 우선순위: P3 / 태그: @feature / 상태: 제안 +- 우선순위: P3 / 태그: @feature / 상태: 구현됨 - 절차: 월간 "이전 달"(9월) → 주간 - 기대: 8월 30일 ~ 9월 5일. 달을 옮기면 날짜가 1일로 맞춰지므로 그 달 1일이 속한 주가 나온다 (의도한 동작) - 근거: src/shared/utils/dateNavigation.ts:13 ### TC-RECORD-009 루틴 행을 누르면 체크한 날이 "했음" 으로 보인다 -- 우선순위: P2 / 태그: @feature / 상태: 제안 +- 우선순위: P2 / 태그: @feature / 상태: 구현됨 - 전제: 매일 루틴 "독서". 기록에서 독서 기록 창을 한 번 열었다 닫는다 - 절차: 홈에서 10-20 체크 → 기록 → "독서 기록 보기" - 기대: "10월 20일 했음" - 근거: useRoutineToggle.ts:96-114 ### TC-RECORD-010 홈에서 추가·삭제한 할 일이 반영된다 -- 우선순위: P2 / 태그: @feature / 상태: 제안 +- 우선순위: P2 / 태그: @feature / 상태: 구현됨 - 전제: 오늘 완료한 할 일 C. 기록을 먼저 연다 - 절차: 홈에서 D 추가 → 기록 → 홈에서 C 삭제 → 기록 - 기대: 1 / 2 와 남은 할 일 D → 0 / 1 과 "이 기간에 해낸 할 일이 없어요." - 근거: useTaskList.ts:227, useTaskModalHandlers.ts:193 ### TC-RECORD-011 홈에서 일괄 이동하면 반영된다 -- 우선순위: P2 / 태그: @feature / 상태: 제안 +- 우선순위: P2 / 태그: @feature / 상태: 구현됨 - 전제: 10-20 에 미완료 2개. 기록을 먼저 연다 - 절차: 홈 리스트 메뉴로 오늘로 옮긴다 → 기록 - 기대: 남은 할 일 2개가 모두 "오늘" - 근거: src/pages/HomePage/components/TaskReport/sections/TaskSetting.tsx:50-76 ### TC-RECORD-012 남은 할 일이 10개를 넘으면 접고 펼친다 -- 우선순위: P3 / 태그: @feature / 상태: 제안 +- 우선순위: P3 / 태그: @feature / 상태: 구현됨 - 전제: 이번 달 미완료 12개 - 기대: 10개 → "2개 더 보기" 12개 → "접기" 10개 - 근거: src/pages/RecordPage/components/RecordSections.tsx:13, 162-170 ### TC-RECORD-013 빈 사용자는 "-" 와 안내 문구를 본다 -- 우선순위: P3 / 태그: @feature / 상태: 제안 +- 우선순위: P3 / 태그: @feature / 상태: 구현됨 - 기대: 달성률 "-", 세 영역의 빈 안내 - 근거: RecordSections.tsx:34, 73-76, 133-136, 187-190 diff --git a/e2e/cases/routine.md b/e2e/cases/routine.md index f94d53c..eb3c384 100644 --- a/e2e/cases/routine.md +++ b/e2e/cases/routine.md @@ -30,67 +30,68 @@ - 근거: RoutineCategorySection.tsx:61-66, useRoutineModalActions.ts:164-177 ### TC-ROUTINE-005 입력 검증 -- 우선순위: P2 / 태그: @feature / 상태: 제안 +- 우선순위: P2 / 태그: @feature / 상태: 구현됨 - 절차: (a) 이름 없이 저장 (b) 요일 없이 저장 - 기대: (a) "루틴 이름을 입력해 주세요." (b) "반복할 요일을 하나 이상 선택해 주세요." 모달이 남고 루틴이 생기지 않는다 - 근거: useRoutineModalActions.ts:134-141 ### TC-ROUTINE-006 시작일을 종료일보다 뒤로 옮기면 종료일이 따라간다 -- 우선순위: P2 / 태그: @feature / 상태: 제안 +- 우선순위: P2 / 태그: @feature / 상태: 구현됨 - 전제: today 2026-10-21 - 절차: 종료 날짜 추가 → 10/25 → 시작 날짜 10/28 - 기대: 종료 날짜가 10/28 이 되고, 종료 달력에서 10/28 이전이 비활성 - 근거: StartDateField.tsx:96-101, EndDateField.tsx:49 ### TC-ROUTINE-007 저장에 실패하면 토스트가 뜨고 입력이 남는다 -- 우선순위: P2 / 태그: @feature / 상태: 제안 +- 우선순위: P2 / 태그: @feature / 상태: 구현됨 - 전제: 쓰기 실패 - 기대: "루틴 저장에 실패했습니다…" 토스트, 모달과 입력값 유지, 버튼이 "저장" 으로 돌아온다 - 근거: useRoutineModalActions.ts:211-215 ### TC-ROUTINE-008 저장을 연타해도 루틴은 하나만 생긴다 -- 우선순위: P2 / 태그: @feature / 상태: 제안 +- 우선순위: P2 / 태그: @feature / 상태: 구현됨 - 절차: 이름 입력란에서 Enter 두 번 → 저장 클릭 - 기대: "독서" 가 1개 - 근거: useRoutineModalActions.ts:110, :155 ### TC-ROUTINE-009 종료일을 지난 날로 정하면 [종료] 가 붙는다 -- 우선순위: P2 / 태그: @feature / 상태: 제안 +- 우선순위: P2 / 태그: @feature / 상태: 구현됨 - 전제: 10-01 시작 루틴. today 2026-10-21 - 절차: 수정 → 종료 날짜 10/20 → 저장 → 제목 클릭 - 기대: "[종료] 독서", 기록 창에 "10월 20일에 끝남", 홈 주간 표 21일에 체크할 곳 없음 - 근거: RoutineItem.tsx:24-25, RoutineHistoryModal.tsx:61-64 ### TC-ROUTINE-010 삭제하면 루틴·홈·기록 모두에서 사라진다 -- 우선순위: P2 / 태그: @feature / 상태: 제안 +- 우선순위: P2 / 태그: @feature / 상태: 구현됨 - 전제: 루틴 2개. 홈과 기록을 한 번씩 열어 둔다 - 절차: 더보기 → 삭제 → 확인 창에서 "취소" → 다시 삭제 → "삭제" → 홈 → 기록 - 기대: "루틴을 삭제할까요?" 확인 창에 수행 기록도 지워진다는 안내가 있다. 취소하면 상세 모달로 돌아가 그대로다. 삭제하면 세 화면 모두에서 사라진다 - 근거: useRoutineModalActions.ts:218-242, RoutineModal/index.tsx (삭제 확인) ### TC-ROUTINE-011 수정 중에 닫으려 하면 확인을 묻는다 -- 우선순위: P2 / 태그: @feature / 상태: 제안 +- 우선순위: P2 / 태그: @feature / 상태: 구현됨 - 절차: (a) 작성 중 Esc (b) 수정 중 바깥 클릭 - 기대: "수정 중인 내용이 있습니다" 확인. 고친 게 없으면 "저장" 이 비활성 - 근거: useEditModalExit.ts, RoutineModal/index.tsx:44-53 ### TC-ROUTINE-012 요일을 그대로 두고 수정하면 시작일이 잠긴다 -- 우선순위: P3 / 태그: @feature / 상태: 제안 +- 우선순위: P3 / 태그: @feature / 상태: 구현됨 - 기대: 시작일은 글자로만 보이고, 요일을 바꾼 동안에만 "변경 적용 날짜" 가 나온다 - 근거: StartDateField.tsx:23-35, 84-90 +- 발견: 날짜 선택기에서 Esc 를 누르면 선택기와 함께 수정 모달까지 닫히려 하던 문제를 찾아 고쳤다(DatePicker.tsx) ### TC-ROUTINE-013 기록 창의 기간 전환과 "루틴 정보" 로 이동 -- 우선순위: P3 / 태그: @feature / 상태: 제안 +- 우선순위: P3 / 태그: @feature / 상태: 구현됨 - 기대: "해야 했던 N번 중 0번 했어요", 시작 전 날짜엔 라벨 없음, "루틴 정보" 로 상세 모달 전환 - 근거: RoutineHistoryModal.tsx:18-21, f852d7d ### TC-ROUTINE-014 카테고리가 없으면 빈 상태 안내를 보여 준다 -- 우선순위: P3 / 태그: @feature / 상태: 제안 +- 우선순위: P3 / 태그: @feature / 상태: 구현됨 - 기대: "아직 카테고리가 없습니다." → "카테고리 만들러 가기" 로 /category - 근거: EmptyRoutineList.tsx:15-24 ### TC-ROUTINE-015 순서 저장에 실패하면 서버 순서로 돌아온다 -- 우선순위: P3 / 태그: @regression / 상태: 제안 +- 우선순위: P3 / 태그: @regression / 상태: 구현됨 - 전제: 루틴 A·B·C. 쓰기 실패 - 절차: C 를 맨 위로 끈다 - 기대: "순서 저장에 실패했습니다…" 토스트, 목록이 A·B·C 로 돌아온다 diff --git a/e2e/drag.ts b/e2e/drag.ts index ec34655..d773de9 100644 --- a/e2e/drag.ts +++ b/e2e/drag.ts @@ -1,10 +1,11 @@ -import type { Locator, Page } from "@playwright/test"; +import { expect, type Locator, type Page } from "@playwright/test"; /** * dnd-kit 끌기를 사람 손처럼 재현한다. * * 앱은 150ms 이상 눌러야 끌기로 본다(SortableList·TaskWeekBoard 의 ACTIVATION_CONSTRAINT). * locator.dragTo() 는 누르자마자 움직여 짧은 누르기로 처리되므로 쓰지 않는다. + * 누른 채로 끌기가 실제로 시작될 때까지 기다린 뒤 움직인다. * * 끄는 도중의 화면을 확인할 수 있게 집기·옮기기·놓기를 나눠 둔다. */ @@ -14,8 +15,10 @@ export const grab = async (page: Page, source: Locator) => { const from = await center(source); await page.mouse.move(from.x, from.y); await page.mouse.down(); - // 고정 대기를 쓰지 않는다는 규칙의 예외. 기다릴 상태가 없고, 시간 자체가 조건이다 - await page.waitForTimeout(250); + // 끌기가 시작되면 dnd-kit 이 끌리는 항목에 aria-pressed="true" 를 단다. 시간을 정해 두고 기다리면 + // 부하가 클 때 앱의 150ms 타이머가 늦게 돌아, 시작되기 전에 움직여 끌기가 취소된다 + const handle = source.locator("xpath=ancestor-or-self::*[@aria-roledescription][1]"); + await expect(handle).toHaveAttribute("aria-pressed", "true"); return { moveTo: async (target: Locator) => { diff --git a/e2e/network.ts b/e2e/network.ts index 76ef5bb..27eda35 100644 --- a/e2e/network.ts +++ b/e2e/network.ts @@ -2,7 +2,7 @@ import { expect, type Page, type Request } from "@playwright/test"; /** * Firestore 요청 다루기. 앱은 firestore/lite 라 요청이 모두 REST 다. - * 쓰기는 `documents:commit`, 읽기는 `:runQuery`·`:batchGet` 로 나간다. + * 쓰기는 `documents:commit`, 읽기는 `documents:batchGet`(문서)과 `documents/users/{uid}:runQuery`(목록)로 나간다. */ const FIRESTORE = /127\.0\.0\.1:8080\//; const WRITE = "**/documents:commit*"; diff --git a/e2e/record/record.spec.ts b/e2e/record/record.spec.ts new file mode 100644 index 0000000..198bd63 --- /dev/null +++ b/e2e/record/record.spec.ts @@ -0,0 +1,22 @@ +import { test } from "../fixtures"; + +/** + * 클릭으로 테스트 만들기. `npm run e2e:record` 로 연다. + * + * 새 테스트 사용자로 로그인하고 예시 데이터를 넣은 뒤 멈춘다. 뜨는 Playwright Inspector 에서 + * Record 를 누르고 화면을 조작하면 그 조작이 테스트 코드로 쌓인다. 끝나면 Inspector 의 코드를 + * 복사해 케이스 파일(e2e/cases)과 함께 넘기거나 spec 에 붙인다. 창을 닫으면 끝난다. + */ +test("녹화", async ({ page, signIn }) => { + const { seed } = await signIn(); + const work = await seed.category({ name: "업무", color: "#0088FF", orderIndex: 0 }); + const health = await seed.category({ name: "건강", color: "#35C759", orderIndex: 1 }); + const today = new Date().toLocaleDateString("sv-SE", { timeZone: "Asia/Seoul" }); + await seed.task({ title: "보고서 쓰기", category: work, date: today, orderIndex: 0 }); + await seed.task({ title: "메일 답장", category: work, date: today, orderIndex: 1 }); + await seed.routine({ title: "스트레칭", categoryId: health.id, orderIndex: 0, startDate: `${today.slice(0, 7)}-01` }); + await seed.routine({ title: "독서", categoryId: health.id, orderIndex: 1, startDate: `${today.slice(0, 7)}-01`, days: [1, 3, 5] }); + + await page.goto("/home"); + await page.pause(); +}); diff --git a/e2e/seed.ts b/e2e/seed.ts index 67229ff..86bae09 100644 --- a/e2e/seed.ts +++ b/e2e/seed.ts @@ -42,66 +42,104 @@ const toValue = (value: unknown): FirestoreValue => { const toFields = (data: Record) => Object.fromEntries(Object.entries(data).map(([key, value]) => [key, toValue(value)])); +/** 경로에 문서 하나를 통째로 쓴다. 보안 규칙을 건너뛴다 */ +const writeDoc = async (path: string, data: Record) => { + const response = await fetch( + `${FIRESTORE_EMULATOR}/v1/projects/${PROJECT_ID}/databases/(default)/documents/${path}`, + { + method: "PATCH", + headers: { Authorization: "Bearer owner", "Content-Type": "application/json" }, + body: JSON.stringify({ fields: toFields(data) }), + }, + ); + if (!response.ok) { + throw new Error(`시드 실패 (${path}): ${response.status} ${await response.text()}`); + } +}; + +/** + * 공지. 사용자 아래가 아니라 모든 사용자가 함께 보는 최상위 컬렉션에 쓴다. + * 병렬로 도는 다른 테스트에도 보이므로 제목에 고유한 값을 넣어 구분한다. + */ +export const seedNotice = async ({ + title, + content = "", + pinned = false, + createdAt = new Date(), +}: { + title: string; + content?: string; + pinned?: boolean; + createdAt?: Date; +}) => { + const id = `notice-${randomSuffix()}`; + await writeDoc(`notices/${id}`, { title, content, pinned, createdAt }); + return { id }; +}; + +const randomSuffix = () => Math.random().toString(36).slice(2, 10); + export const createSeeder = (uid: string) => { - const write = async (collection: string, id: string, data: Record) => { - const path = `users/${uid}/${collection}/${id}`; - const response = await fetch( - `${FIRESTORE_EMULATOR}/v1/projects/${PROJECT_ID}/databases/(default)/documents/${path}`, - { - method: "PATCH", - headers: { Authorization: "Bearer owner", "Content-Type": "application/json" }, - body: JSON.stringify({ fields: toFields(data) }), - }, - ); - if (!response.ok) { - throw new Error(`시드 실패 (${path}): ${response.status} ${await response.text()}`); - } - }; + const write = (collection: string, id: string, data: Record) => + writeDoc(`users/${uid}/${collection}/${id}`, data); return { + /** ended 를 넘기면 그날 종료한 카테고리가 된다 */ category: async ({ name, color = "#6155F5", orderIndex = 0, + ended, }: { name: string; color?: string; orderIndex?: number; + ended?: Date; }) => { const id = newId("category"); const now = new Date(); await write("categories", id, { name, color, - status: "ACTIVE", + status: ended ? "ENDED" : "ACTIVE", orderIndex, createdAt: now, updatedAt: now, + ...(ended && { endedAt: ended }), }); return { id, color }; }, - /** days 는 0(일)~6(토). 기본은 매일 */ + /** + * days 는 0(일)~6(토). 기본은 매일. + * scheduleHistory 를 넘기면 요일을 바꿔 온 이력이 된다. 마지막 구간의 요일이 지금 요일(days)이다. + */ routine: async ({ title, categoryId, orderIndex, startDate, days = [0, 1, 2, 3, 4, 5, 6], + endDate, + scheduleHistory, }: { title: string; categoryId: string; orderIndex: number; startDate: string; days?: number[]; + endDate?: string; + scheduleHistory?: { effectiveFrom: string; days: number[] }[]; }) => { const id = newId("routine"); + const history = scheduleHistory ?? [{ effectiveFrom: startDate, days }]; await write("routines", id, { title, categoryId, - days, - scheduleHistory: [{ effectiveFrom: startDate, days }], + days: history[history.length - 1].days, + scheduleHistory: history, startDate, + ...(endDate && { endDate }), orderIndex, status: "ACTIVE", createdAt: new Date(), @@ -114,11 +152,14 @@ export const createSeeder = (uid: string) => { category, date, orderIndex = 0, + time, }: { title: string; category: { id: string; color: string }; date: string; orderIndex?: number; + /** "HH:mm" */ + time?: string; }) => { const id = newId("task"); const now = new Date(); @@ -127,6 +168,7 @@ export const createSeeder = (uid: string) => { categoryId: category.id, categoryColor: category.color, date, + ...(time && { time }), orderIndex, createdAt: now, updatedAt: now, @@ -152,6 +194,41 @@ export const createSeeder = (uid: string) => { }); }, + /** + * 한 달의 월간 통계 문서. days 의 키는 "01"~"31". + * 문서가 없는 달은 홈이 원본에서 세어 만들므로, 이미 있는 상태나 옛 문서(version 1)를 재현할 때만 쓴다. + */ + monthlyStats: async ({ + month, + days, + version = 2, + }: { + month: string; + days: Record< + string, + { + total: number; + completed: number; + remaining: number; + taskTotal?: number; + taskCompleted?: number; + routineTotal?: number; + routineCompleted?: number; + } + >; + version?: number; + }) => { + const withActivity = Object.fromEntries( + Object.entries(days).map(([day, value]) => [day, { ...value, hasActivity: value.total > 0 }]), + ); + await write("monthlyStats", month, { + month, + days: withActivity, + version, + updatedAt: new Date(), + }); + }, + /** 문서 ID 는 앱과 같이 `{taskId}_{date}` 로 둔다 */ taskLog: async ({ taskId, diff --git a/e2e/specs/category/category-p2.spec.ts b/e2e/specs/category/category-p2.spec.ts new file mode 100644 index 0000000..33a5ebc --- /dev/null +++ b/e2e/specs/category/category-p2.spec.ts @@ -0,0 +1,222 @@ +import type { Page } from "@playwright/test"; +import { test, expect } from "../../fixtures"; +import { dragTo } from "../../drag"; +import { failWrites, waitForSaved } from "../../network"; + +test.use({ today: "2026-10-21" }); + +/** 진행중 목록의 카테고리 이름을 차례대로. 종료된 카테고리는 기간(~)이 붙어 빠진다 */ +const activeNames = (page: Page) => + page + .getByRole("heading", { name: "진행중인 카테고리" }) + .locator("xpath=ancestor::div[contains(@class,'flex-col')][1]") + .getByLabel(/ 카테고리 메뉴 열기$/) + .evaluateAll((buttons) => + buttons.map((button) => button.getAttribute("aria-label")!.replace(/ 카테고리 메뉴 열기$/, "")), + ); + +const openMenu = (page: Page, name: string) => + page.getByRole("button", { name: `${name} 카테고리 메뉴 열기`, exact: true }).click(); + +const endCategory = async (page: Page, name: string) => { + await openMenu(page, name); + await page.getByRole("dialog", { name: "카테고리 상세" }).getByRole("button", { name: "종료", exact: true }).click(); + await page.getByRole("dialog", { name: "카테고리를 종료할까요?" }).getByRole("button", { name: "종료" }).click(); + await expect(page.getByRole("dialog")).toHaveCount(0); +}; + +const createCategory = async (page: Page, name: string) => { + await page.getByRole("button", { name: "추가", exact: true }).click(); + const dialog = page.getByRole("dialog", { name: "카테고리 추가" }); + await dialog.getByLabel("카테고리 이름").fill(name); + await dialog.getByRole("button", { name: "저장" }).click(); +}; + +test.describe("카테고리: 순서", { tag: "@feature" }, () => { + test( + "TC-CATEGORY-002 가운데를 종료한 뒤 새로 만든 카테고리는 맨 뒤에 붙는다", + { tag: "@regression", annotation: { type: "출처", description: "a8ddb5f, 5d1cd88" } }, + async ({ page, signIn }) => { + const { seed } = await signIn(); + for (const [orderIndex, name] of ["A", "B", "C"].entries()) await seed.category({ name, orderIndex }); + await page.goto("/category"); + await expect.poll(() => activeNames(page)).toEqual(["A", "B", "C"]); + + await endCategory(page, "B"); + await createCategory(page, "D"); + await expect(page.getByRole("dialog")).toHaveCount(0); + await expect.poll(() => activeNames(page)).toEqual(["A", "C", "D"]); + }, + ); + + test("TC-CATEGORY-006 복구하면 진행중 목록 맨 뒤로 돌아온다", async ({ page, signIn }) => { + const { seed } = await signIn(); + await seed.category({ name: "A", orderIndex: 0 }); + await seed.category({ name: "B", orderIndex: 1 }); + await seed.category({ name: "C", orderIndex: 2, ended: new Date("2026-10-10T10:00:00+09:00") }); + await page.goto("/category"); + await expect(page.getByRole("heading", { name: "종료된 카테고리" })).toBeVisible(); + + await openMenu(page, "C"); + await page.getByRole("dialog", { name: "카테고리 상세" }).getByRole("button", { name: "복구" }).click(); + await expect(page.getByRole("dialog")).toHaveCount(0); + await expect.poll(() => activeNames(page)).toEqual(["A", "B", "C"]); + // 종료된 카테고리가 하나도 남지 않으면 섹션째 사라진다 + await expect(page.getByRole("heading", { name: "종료된 카테고리" })).toHaveCount(0); + }); +}); + +test.describe("카테고리: 입력과 저장", { tag: "@feature" }, () => { + test("TC-CATEGORY-007 이름이 비었거나 공백뿐이면 저장되지 않는다", async ({ page, signIn }) => { + await signIn(); + await page.goto("/category"); + await page.getByRole("button", { name: "추가", exact: true }).click(); + const dialog = page.getByRole("dialog", { name: "카테고리 추가" }); + const name = dialog.getByLabel("카테고리 이름"); + + await name.fill(" "); + await expect(dialog.getByRole("button", { name: "저장" })).toBeDisabled(); + await name.press("Enter"); + await expect(page.getByText("카테고리 이름을 입력해 주세요.")).toBeVisible(); + + // 색만 바꾸면 저장은 눌리지만 이름이 없어 만들어지지 않는다 + await name.fill(""); + await dialog.getByRole("button", { name: "색상 선택" }).click(); + await dialog.getByRole("button", { name: "blue" }).click(); + await dialog.getByRole("button", { name: "저장" }).click(); + await expect(dialog).toBeVisible(); + + await name.fill("가".repeat(21)); + await expect(name).toHaveValue("가".repeat(20)); + await page.keyboard.press("Escape"); + await page.getByRole("dialog", { name: "수정 중인 내용이 있습니다" }).getByRole("button", { name: "닫기" }).click(); + await expect(page.getByText("진행중인 카테고리가 없습니다.")).toBeVisible(); + }); + + test("TC-CATEGORY-010 저장을 연달아 눌러도 하나만 만들어진다", async ({ page, signIn }) => { + await signIn(); + await page.goto("/category"); + // 저장이 끝나기 전에 다시 누를 틈을 만든다 + await page.route("**/documents:commit*", async (route) => { + await new Promise((resolve) => setTimeout(resolve, 1_000)); + await route.fallback(); + }); + await page.getByRole("button", { name: "추가", exact: true }).click(); + const dialog = page.getByRole("dialog", { name: "카테고리 추가" }); + await dialog.getByLabel("카테고리 이름").fill("공부"); + await dialog.getByRole("button", { name: "저장" }).dblclick(); + await expect(dialog.getByRole("button", { name: "저장 중..." })).toBeVisible(); + await expect(page.getByRole("dialog")).toHaveCount(0); + await waitForSaved(page); + await page.reload(); + await expect(page.getByRole("button", { name: "공부 카테고리 메뉴 열기", exact: true })).toHaveCount(1); + }); + + test("TC-CATEGORY-011 수정 중에 닫으려 하면 확인을 묻는다", async ({ page, signIn }) => { + const { seed } = await signIn(); + await seed.category({ name: "A" }); + await page.goto("/category"); + const detail = page.getByRole("dialog", { name: "카테고리 상세" }); + const edit = page.getByRole("dialog", { name: "카테고리 수정" }); + const guard = page.getByRole("dialog", { name: "수정 중인 내용이 있습니다" }); + + // 공백만 덧붙인 것은 고친 것이 아니라 묻지 않는다 + await openMenu(page, "A"); + await detail.getByRole("button", { name: "수정" }).click(); + await edit.getByLabel("카테고리 이름").fill("A "); + await page.keyboard.press("Escape"); + await expect(guard).toHaveCount(0); + await expect(detail).toBeVisible(); + + await detail.getByRole("button", { name: "수정" }).click(); + await edit.getByLabel("카테고리 이름").fill("새 이름"); + await page.mouse.click(5, 5); + await expect(guard).toBeVisible(); + await guard.getByRole("button", { name: "닫기" }).click(); + // 상세에서 들어온 수정은 버리면 상세로 돌아간다 + await expect(detail).toBeVisible(); + await expect(detail.getByLabel("카테고리 이름")).toHaveValue("A"); + }); +}); + +test.describe("카테고리: 저장 실패", { tag: "@feature" }, () => { + test.use({ expectedError: /status of 500|Internal Server Error/ }); + + test("TC-CATEGORY-008 저장에 실패하면 알리고 모달이 남는다", async ({ page, signIn }) => { + const { seed } = await signIn(); + await seed.category({ name: "A" }); + await page.goto("/category"); + await expect(page.getByRole("button", { name: "A 카테고리 메뉴 열기", exact: true })).toBeVisible(); + await waitForSaved(page); + await failWrites(page); + + await createCategory(page, "B"); + await expect(page.getByText("카테고리 생성에 실패했습니다. 잠시 후 다시 시도해 주세요.")).toBeVisible(); + await expect(page.getByRole("dialog", { name: "카테고리 추가" })).toBeVisible(); + await page.keyboard.press("Escape"); + await page.getByRole("dialog", { name: "수정 중인 내용이 있습니다" }).getByRole("button", { name: "닫기" }).click(); + + await openMenu(page, "A"); + const detail = page.getByRole("dialog", { name: "카테고리 상세" }); + await detail.getByRole("button", { name: "수정" }).click(); + const edit = page.getByRole("dialog", { name: "카테고리 수정" }); + await edit.getByLabel("카테고리 이름").fill("A2"); + await edit.getByRole("button", { name: "저장" }).click(); + await expect(page.getByText("카테고리 수정에 실패했습니다. 잠시 후 다시 시도해 주세요.")).toBeVisible(); + await expect(edit).toBeVisible(); + // 취소는 버리겠다는 뜻이 분명해 묻지 않고 상세로 돌아간다 + await edit.getByRole("button", { name: "취소" }).click(); + + await detail.getByRole("button", { name: "종료", exact: true }).click(); + await page.getByRole("dialog", { name: "카테고리를 종료할까요?" }).getByRole("button", { name: "종료" }).click(); + await expect(page.getByText("카테고리 종료에 실패했습니다. 잠시 후 다시 시도해 주세요.")).toBeVisible(); + await expect(detail).toBeVisible(); + await expect.poll(() => activeNames(page)).toEqual(["A"]); + }); + + test("TC-CATEGORY-009 순서 저장에 실패하면 원래 순서로 돌아간다", async ({ page, signIn }) => { + const { seed } = await signIn(); + for (const [orderIndex, name] of ["A", "B", "C"].entries()) await seed.category({ name, orderIndex }); + await page.goto("/category"); + await expect.poll(() => activeNames(page)).toEqual(["A", "B", "C"]); + await failWrites(page); + + await dragTo(page, page.getByText("C", { exact: true }), page.getByText("A", { exact: true })); + await expect(page.getByText("순서 저장에 실패했습니다. 잠시 후 다시 시도해 주세요.")).toBeVisible(); + await expect.poll(() => activeNames(page)).toEqual(["A", "B", "C"]); + }); +}); + +test.describe("카테고리: 빈 상태와 읽기 실패", { tag: "@feature" }, () => { + test("TC-CATEGORY-012 첫 사용자에게 빈 화면 안내가 보인다", async ({ page, signIn }) => { + await signIn(); + await page.goto("/category"); + await expect(page.getByText("진행중인 카테고리가 없습니다.")).toBeVisible(); + await expect(page.getByRole("heading", { name: "종료된 카테고리" })).toHaveCount(0); + await page.getByRole("button", { name: "카테고리 추가", exact: true }).click(); + await expect(page.getByRole("dialog", { name: "카테고리 추가" })).toBeVisible(); + }); + + test.describe(() => { + test.use({ expectedError: /status of 500|Internal Server Error/ }); + + test("TC-CATEGORY-013 목록을 읽지 못하면 다시 시도할 수 있다", async ({ page, signIn }) => { + const { seed } = await signIn(); + await seed.category({ name: "A" }); + // 첫 조회와 자동 재시도 한 번을 모두 실패시킨다 + let failures = 0; + // 사용자 데이터 조회는 .../documents/users/{uid}:runQuery 로 나간다 + await page.route("**/*:runQuery*", (route) => { + if (failures < 2 && route.request().postData()?.includes('"categories"')) { + failures += 1; + return route.fulfill({ status: 500, body: "{}" }); + } + return route.fallback(); + }); + await page.goto("/category"); + await expect(page.getByText("카테고리를 불러오지 못했습니다.").first()).toBeVisible(); + await page.getByRole("button", { name: "다시 시도" }).first().click(); + await expect(page.getByRole("button", { name: "A 카테고리 메뉴 열기", exact: true })).toBeVisible(); + }); + }); +}); diff --git a/e2e/specs/common/common.spec.ts b/e2e/specs/common/common.spec.ts new file mode 100644 index 0000000..78d44e6 --- /dev/null +++ b/e2e/specs/common/common.spec.ts @@ -0,0 +1,55 @@ +import { test, expect } from "../../fixtures"; +import { seedNotice } from "../../seed"; + +test.describe("공통", { tag: "@feature" }, () => { + test("TC-COMMON-002 로그인한 사용자가 / 나 /login 에 가면 /home 으로 간다", async ({ page, signIn }) => { + await signIn(); + for (const path of ["/", "/login"]) { + await page.goto(path); + await expect(page).toHaveURL(/\/home$/); + await expect(page.getByRole("heading", { name: "달성 현황" })).toBeVisible(); + } + }); + + test("TC-COMMON-003 공지는 고정 공지가 먼저, 상세에서 이전·다음으로 오간다", async ({ page, signIn }) => { + // 공지는 모든 사용자가 함께 본다. 다른 실행이 남긴 공지와 섞이지 않게 고유 제목을 쓰고, + // 이 테스트의 공지가 가장 최근이 되도록 앞으로의 시각을 준다. + // 세 공지를 1ms 간격으로 붙여 두어, 동시에 도는 다른 실행의 공지가 사이에 끼지 못하게 한다 + const run = Date.now().toString(36); + const base = Date.now() + 24 * 60 * 60_000; + await seedNotice({ title: `고정 ${run}`, pinned: true, createdAt: new Date(base) }); + await seedNotice({ title: `새 공지 ${run}`, createdAt: new Date(base + 2) }); + await seedNotice({ title: `옛 공지 ${run}`, createdAt: new Date(base + 1) }); + await signIn(); + await page.goto("/home"); + + await page.getByRole("button", { name: "공지", exact: true }).click(); + const list = page.getByRole("dialog", { name: "공지 목록" }); + const ours = list.getByRole("button").filter({ hasText: run }); + await expect(ours).toHaveText([new RegExp(`^고정 ${run}`), new RegExp(`^새 공지 ${run}`), new RegExp(`^옛 공지 ${run}`)]); + + await ours.nth(1).click(); + const detail = page.getByRole("dialog", { name: "공지사항" }); + await expect(detail).toContainText(`새 공지 ${run}`); + await detail.getByRole("button", { name: `다음 공지: 옛 공지 ${run}` }).click(); + await expect(detail).toContainText(`옛 공지 ${run}`); + + await detail.getByRole("button", { name: "공지 목록으로 돌아가기" }).click(); + await expect(list).toBeVisible(); + await list.getByRole("button", { name: "닫기" }).click(); + await expect(page.getByRole("dialog")).toHaveCount(0); + }); + + test( + "TC-COMMON-004 푸터에서 약관으로 가고, 인스타그램은 새 창으로 열린다", + { annotation: { type: "출처", description: "10d7c86" } }, + async ({ page, signIn }) => { + await signIn(); + await page.goto("/home"); + await expect(page.getByRole("link", { name: "checky 인스타그램 (새 창)" })).toHaveAttribute("target", "_blank"); + await page.getByRole("contentinfo").getByRole("link", { name: "이용약관" }).click(); + await expect(page).toHaveURL(/\/terms$/); + await expect(page.getByRole("heading", { level: 1 })).toBeVisible(); + }, + ); +}); diff --git a/e2e/specs/home/home-p2.spec.ts b/e2e/specs/home/home-p2.spec.ts new file mode 100644 index 0000000..44aa1d3 --- /dev/null +++ b/e2e/specs/home/home-p2.spec.ts @@ -0,0 +1,235 @@ +import type { Page } from "@playwright/test"; +import { test, expect } from "../../fixtures"; +import { dragTo } from "../../drag"; +import { failWrites, waitForSaved } from "../../network"; + +// 10월 21일(수). 그 주는 10월 18일(일)~24일(토) +test.use({ today: "2026-10-21" }); + +const region = (page: Page, name: string) => page.getByRole("region", { name, exact: true }); +const calendarDay = (page: Page, day: number) => + region(page, "캘린더").getByRole("button", { name: new RegExp(`^10월 ${day}일(,|$)`) }); +const taskSubtitle = (page: Page, text: string | RegExp) => region(page, "할 일 목록").getByText(text); + +const openListMenu = async (page: Page, option: string) => { + await page.getByRole("button", { name: "리스트 메뉴" }).click(); + const menu = page.getByRole("dialog", { name: "리스트 메뉴" }); + await menu.getByRole("radio", { name: option }).click(); + await menu.getByRole("button", { name: "완료" }).click(); +}; + +test.describe("홈: 할 일 옮기기", { tag: "@feature" }, () => { + test("TC-HOME-007 \"이동\" 으로 완료한 할 일을 다른 날로 옮긴다", async ({ page, signIn }) => { + const { seed } = await signIn(); + const category = await seed.category({ name: "업무" }); + const task = await seed.task({ title: "보고서", category, date: "2026-10-21" }); + await seed.taskLog({ taskId: task.id, date: "2026-10-21", completed: true }); + await page.goto("/home"); + + await page.getByRole("button", { name: "보고서 상세 보기", exact: true }).click(); + await page.getByRole("dialog", { name: "태스크 상세" }).getByRole("button", { name: "이동" }).click(); + const pick = page.getByRole("dialog", { name: "날짜 선택" }); + await pick.getByRole("button", { name: /2026/ }).click(); + await page.getByRole("button", { name: "2026년 10월 23일" }).click(); + await pick.getByRole("button", { name: "이동" }).click(); + await expect(page.getByRole("dialog")).toHaveCount(0); + await expect(page.getByRole("checkbox", { name: "보고서" })).toHaveCount(0); + + await calendarDay(page, 23).click(); + await expect(taskSubtitle(page, /^2026년 10월 23일/)).toBeVisible(); + await expect(page.getByRole("checkbox", { name: "보고서" })).toBeChecked(); + // 완료한 채로 옮겼으므로 23일의 남은 일은 없다 + await expect(region(page, "캘린더").getByRole("button", { name: "10월 23일, 남은 일 0개" })).toBeVisible(); + }); + + test("TC-HOME-008 리스트 메뉴로 미완료 할 일을 오늘로 옮긴다", async ({ page, signIn }) => { + const { seed } = await signIn(); + const category = await seed.category({ name: "업무" }); + await seed.task({ title: "미완료", category, date: "2026-10-20", orderIndex: 0 }); + const done = await seed.task({ title: "완료함", category, date: "2026-10-20", orderIndex: 1 }); + await seed.taskLog({ taskId: done.id, date: "2026-10-20", completed: true }); + await page.goto("/home"); + + // 오늘 목록에서 하면 옮길 것이 없다고 알린다 + await openListMenu(page, "미완료 할 일을 오늘 하기"); + await expect(page.getByText("이미 오늘 목록입니다.")).toBeVisible(); + await expect(page.getByRole("dialog", { name: "리스트 메뉴" })).toHaveCount(0); + + await region(page, "할 일 목록").getByRole("button", { name: "이전" }).click(); + await expect(taskSubtitle(page, /^2026년 10월 20일/)).toBeVisible(); + await openListMenu(page, "미완료 할 일을 오늘 하기"); + await expect(page.getByRole("checkbox", { name: "미완료" })).toHaveCount(0); + await expect(page.getByRole("checkbox", { name: "완료함" })).toBeChecked(); + + await region(page, "할 일 목록").getByRole("button", { name: "오늘" }).click(); + await expect(taskSubtitle(page, /^2026년 10월 21일/)).toBeVisible(); + await expect(page.getByRole("checkbox", { name: "미완료" })).not.toBeChecked(); + }); + + test("TC-HOME-017 리스트 메뉴로 미완료 할 일을 다른 날(다음 날)로 옮긴다", async ({ page, signIn }) => { + const { seed } = await signIn(); + const category = await seed.category({ name: "업무" }); + await seed.task({ title: "미완료", category, date: "2026-10-21", orderIndex: 0 }); + const done = await seed.task({ title: "완료함", category, date: "2026-10-21", orderIndex: 1 }); + await seed.taskLog({ taskId: done.id, date: "2026-10-21", completed: true }); + await page.goto("/home"); + await expect(page.getByRole("checkbox", { name: "미완료" })).toBeVisible(); + + // 녹화한 흐름: 리스트 메뉴 → 다른 날 하기 → 완료 → 날짜 선택(기본값 다음 날) → 이동 + await openListMenu(page, "미완료 할 일을 다른 날 하기"); + const pick = page.getByRole("dialog", { name: "날짜 선택" }); + await expect(pick.getByRole("button", { name: "2026. 10. 22." })).toBeVisible(); + await pick.getByRole("button", { name: "이동" }).click(); + await expect(pick).toHaveCount(0); + + // 완료한 할 일은 남고 미완료만 옮겨진다 + await expect(page.getByRole("checkbox", { name: "미완료" })).toHaveCount(0); + await expect(page.getByRole("checkbox", { name: "완료함" })).toBeChecked(); + await expect(calendarDay(page, 21)).toHaveAccessibleName("10월 21일, 남은 일 0개"); + await expect(calendarDay(page, 22)).toHaveAccessibleName("10월 22일, 남은 일 1개"); + + await region(page, "할 일 목록").getByRole("button", { name: "다음" }).click(); + await expect(taskSubtitle(page, /^2026년 10월 22일/)).toBeVisible(); + await expect(page.getByRole("checkbox", { name: "미완료" })).not.toBeChecked(); + + // 옮길 것이 없는 날에서 하면 알린다 + await region(page, "할 일 목록").getByRole("button", { name: "오늘" }).click(); + await openListMenu(page, "미완료 할 일을 다른 날 하기"); + await pick.getByRole("button", { name: "이동" }).click(); + await expect(page.getByText("옮길 미완료 할 일이 없습니다.")).toBeVisible(); + }); + + test("TC-HOME-010 한 주 보기에서 끌어 옮기면 개수와 하루 보기에 반영된다", async ({ page, signIn }) => { + const { seed } = await signIn(); + const category = await seed.category({ name: "업무" }); + await seed.task({ title: "보고서", category, date: "2026-10-21" }); + await page.goto("/home"); + await page.getByRole("button", { name: "한 주", exact: true }).click(); + + await dragTo(page, page.getByLabel("보고서", { exact: true }), region(page, "10월 23일")); + await expect(region(page, "10월 21일")).toContainText("할 일 0"); + await expect(region(page, "10월 23일")).toContainText("할 일 1"); + await expect(taskSubtitle(page, /0 \/ 1 완료$/)).toBeVisible(); + + await page.getByRole("button", { name: "10월 23일 하루 보기" }).click(); + await expect(taskSubtitle(page, /^2026년 10월 23일/)).toBeVisible(); + await expect(page.getByRole("checkbox", { name: "보고서" })).toBeVisible(); + }); +}); + +test.describe("홈: 일괄 삭제", { tag: "@feature" }, () => { + test("TC-HOME-009 모든 할 일 삭제는 확인을 거치고, 대상이 없으면 알린다", async ({ page, signIn }) => { + const { seed } = await signIn(); + const category = await seed.category({ name: "업무" }); + await seed.task({ title: "A", category, date: "2026-10-21", orderIndex: 0 }); + await seed.task({ title: "B", category, date: "2026-10-21", orderIndex: 1 }); + await page.goto("/home"); + const confirm = page.getByRole("dialog", { name: "모든 할 일을 삭제할까요?" }); + + await openListMenu(page, "모든 할 일 삭제"); + await confirm.getByRole("button", { name: "취소" }).click(); + await expect(page.getByRole("checkbox")).toHaveCount(2); + + await openListMenu(page, "모든 할 일 삭제"); + await confirm.getByRole("button", { name: "삭제" }).click(); + await expect(confirm).toHaveCount(0); + await expect(page.getByRole("checkbox")).toHaveCount(0); + await expect(taskSubtitle(page, "2026년 10월 21일")).toBeVisible(); + + // 지울 것이 없으면 알리고 확인 창을 남겨 둔다 + await openListMenu(page, "모든 할 일 삭제"); + await confirm.getByRole("button", { name: "삭제" }).click(); + await expect(page.getByText("삭제할 할 일이 없습니다.")).toBeVisible(); + await expect(confirm).toBeVisible(); + }); +}); + +test.describe("홈: 루틴과 캘린더", { tag: "@feature" }, () => { + test("TC-HOME-012 루틴 표 체크가 합계·캘린더에 반영된다", async ({ page, signIn }) => { + const { seed } = await signIn(); + const category = await seed.category({ name: "건강" }); + await seed.routine({ title: "독서", categoryId: category.id, orderIndex: 0, startDate: "2026-10-01" }); + await page.goto("/home"); + const table = region(page, "루틴 목록"); + const totalRow = table.getByRole("row").filter({ hasText: /^합계/ }); + + await expect(calendarDay(page, 21)).toHaveAccessibleName("10월 21일, 남은 일 1개"); + await table.getByRole("button", { name: "독서 10월 21일", exact: true }).click(); + + const checked = async () => { + await expect(table.getByRole("button", { name: "독서 10월 21일", pressed: true })).toBeVisible(); + await expect(table.getByRole("row").filter({ hasText: /^독서/ })).toContainText("1 / 7"); + await expect(totalRow).toContainText("1/1"); + await expect(calendarDay(page, 21)).toHaveAccessibleName("10월 21일, 남은 일 0개"); + }; + await checked(); + await waitForSaved(page); + await page.reload(); + await checked(); + }); + + test("TC-HOME-013 캘린더 날짜를 누르면 모든 섹션이 그 날을 따라간다", async ({ page, signIn }) => { + const { seed } = await signIn(); + const category = await seed.category({ name: "업무" }); + await seed.task({ title: "지난주 일", category, date: "2026-10-14" }); + await page.goto("/home"); + + await calendarDay(page, 14).click(); + await expect(taskSubtitle(page, /^2026년 10월 14일/)).toBeVisible(); + await expect(page.getByRole("checkbox", { name: "지난주 일" })).toBeVisible(); + await expect(region(page, "루틴 목록").getByText("10월 11일 ~ 10월 17일")).toBeVisible(); + await expect(region(page, "달성 현황").getByText("10월 11일 ~ 10월 17일")).toBeVisible(); + + await region(page, "할 일 목록").getByRole("button", { name: "오늘" }).click(); + await expect(taskSubtitle(page, /^2026년 10월 21일/)).toBeVisible(); + await expect(region(page, "루틴 목록").getByText("10월 18일 ~ 10월 24일")).toBeVisible(); + }); + + test("TC-HOME-014 새 사용자는 카테고리를 만들러 갈 수 있다", async ({ page, signIn }) => { + await signIn(); + await page.goto("/home"); + await expect(page.getByText("아직 카테고리가 없어요.")).toBeVisible(); + await page.getByRole("link", { name: "카테고리 만들기" }).click(); + await expect(page).toHaveURL(/\/category$/); + }); +}); + +test.describe("홈: 실패", { tag: "@feature" }, () => { + test.use({ expectedError: /status of 500|Internal Server Error/ }); + + test("TC-HOME-011 끌어 옮기기에 실패하면 원래 날로 돌아간다", async ({ page, signIn }) => { + const { seed } = await signIn(); + const category = await seed.category({ name: "업무" }); + await seed.task({ title: "보고서", category, date: "2026-10-21" }); + await page.goto("/home"); + await page.getByRole("button", { name: "한 주", exact: true }).click(); + await expect(page.getByLabel("보고서", { exact: true })).toBeVisible(); + await waitForSaved(page); + await failWrites(page); + + await dragTo(page, page.getByLabel("보고서", { exact: true }), region(page, "10월 23일")); + await expect(page.getByText("할 일 이동에 실패했습니다. 잠시 후 다시 시도해 주세요.")).toBeVisible(); + await expect(region(page, "10월 21일").getByLabel("보고서", { exact: true })).toBeVisible(); + await expect(region(page, "10월 23일")).toContainText("할 일 0"); + }); + + test("TC-HOME-015 루틴 목록을 불러오지 못하면 다시 시도할 수 있다", async ({ page, signIn }) => { + const { seed } = await signIn(); + const category = await seed.category({ name: "건강" }); + await seed.routine({ title: "독서", categoryId: category.id, orderIndex: 0, startDate: "2026-10-01" }); + // 루틴 조회를 다시 시도하기 전까지 모두 실패시킨다(자동 재시도도 한 번 있다) + let failing = true; + await page.route("**/*:runQuery*", (route) => + failing && route.request().postData()?.includes('"routines"') + ? route.fulfill({ status: 500, body: "{}" }) + : route.fallback(), + ); + await page.goto("/home"); + const table = region(page, "루틴 목록"); + await expect(table.getByText("루틴 목록을 불러오지 못했습니다.")).toBeVisible(); + + failing = false; + await table.getByRole("button", { name: "다시 시도" }).click(); + await expect(table.getByRole("button", { name: "독서 10월 21일", exact: true })).toBeVisible(); + }); +}); diff --git a/e2e/specs/my/my-p2.spec.ts b/e2e/specs/my/my-p2.spec.ts new file mode 100644 index 0000000..d9d1de0 --- /dev/null +++ b/e2e/specs/my/my-p2.spec.ts @@ -0,0 +1,62 @@ +import { test, expect } from "../../fixtures"; + +test.use({ today: "2026-10-21" }); + +test.describe("마이", { tag: "@feature" }, () => { + test( + "TC-MY-005 다크 테마를 고르면 바로 바뀌고 새로고침해도 유지된다", + { annotation: { type: "출처", description: "40d07b7" } }, + async ({ page, signIn }) => { + await signIn(); + await page.goto("/my"); + const dark = page.getByRole("button", { name: "다크", exact: true }); + + await dark.click(); + await expect(dark).toHaveAttribute("aria-pressed", "true"); + await expect(page.locator("html")).toHaveClass(/\bdark\b/); + await page.reload(); + await expect(page.getByRole("button", { name: "다크", exact: true })).toHaveAttribute("aria-pressed", "true"); + await expect(page.locator("html")).toHaveClass(/\bdark\b/); + + await page.getByRole("button", { name: "라이트", exact: true }).click(); + await expect(page.locator("html")).not.toHaveClass(/\bdark\b/); + }, + ); + + test( + "TC-MY-006 프로필과 최근 6개월 달성률을 보여 준다", + { annotation: { type: "출처", description: "1fce43f, 9a95573" } }, + async ({ page, signIn }) => { + const { email, seed } = await signIn(); + await seed.monthlyStats({ + month: "2026-09", + days: { + "10": { total: 4, completed: 3, remaining: 1, taskTotal: 4, taskCompleted: 3, routineTotal: 0, routineCompleted: 0 }, + "20": { total: 6, completed: 4, remaining: 2, taskTotal: 6, taskCompleted: 4, routineTotal: 0, routineCompleted: 0 }, + }, + }); + await page.goto("/my"); + + await expect(page.getByText("E2E 사용자", { exact: true })).toBeVisible(); + await expect(page.getByText(email, { exact: true })).toBeVisible(); + const rows = page.getByRole("listitem"); + await expect(rows).toHaveCount(6); + await expect(rows.first()).toContainText("5월"); + await expect(rows.filter({ hasText: /^9월/ })).toContainText("70%"); + await expect(rows.filter({ hasText: /^8월/ })).toContainText("기록 없음"); + }, + ); + + test("TC-MY-007 탈퇴 확인에서 취소하면 아무것도 바뀌지 않는다", async ({ page, signIn }) => { + await signIn(); + await page.goto("/my"); + let popupOpened = false; + page.on("popup", () => (popupOpened = true)); + + await page.getByRole("button", { name: "회원탈퇴" }).click(); + await page.getByRole("dialog", { name: "정말로 탈퇴하시겠습니까?" }).getByRole("button", { name: "취소" }).click(); + await expect(page.getByRole("dialog")).toHaveCount(0); + await expect(page).toHaveURL(/\/my$/); + expect(popupOpened).toBe(false); + }); +}); diff --git a/e2e/specs/record/record-p2.spec.ts b/e2e/specs/record/record-p2.spec.ts new file mode 100644 index 0000000..1da3ac6 --- /dev/null +++ b/e2e/specs/record/record-p2.spec.ts @@ -0,0 +1,180 @@ +import type { Page } from "@playwright/test"; +import { test, expect } from "../../fixtures"; +import { waitForSaved } from "../../network"; + +const tile = (page: Page, label: string) => + page.getByRole("term").filter({ hasText: new RegExp(`^${label}$`) }).locator("xpath=following-sibling::dd[1]"); +const section = (page: Page, heading: string) => + page.locator("section").filter({ has: page.getByRole("heading", { name: heading, exact: true }) }); +const period = (page: Page) => page.getByRole("heading", { name: "돌아보기" }).locator("xpath=following-sibling::p[1]"); +const goTo = async (page: Page, menu: "홈" | "기록") => { + await page.getByRole("link", { name: menu, exact: true }).click(); + await expect(page.getByRole("heading", { name: menu === "기록" ? "돌아보기" : "달성 현황" })).toBeVisible(); +}; +const weekMode = (page: Page) => page.getByRole("group", { name: "기간" }).getByRole("button", { name: "주간" }).click(); + +test.describe("기록: 기간 이동", { tag: "@feature" }, () => { + test.describe(() => { + test.use({ today: "2026-10-21" }); + + test("TC-RECORD-006 이전·다음으로 오가며 기간이 바뀌고, 미래 기간은 세지 않는다", async ({ page, signIn }) => { + const { seed } = await signIn(); + const category = await seed.category({ name: "공부" }); + await seed.task({ title: "9월 일", category, date: "2026-09-15" }); + await seed.task({ title: "10월 일 1", category, date: "2026-10-10", orderIndex: 0 }); + await seed.task({ title: "10월 일 2", category, date: "2026-10-11", orderIndex: 1 }); + await page.goto("/record"); + await expect(period(page)).toHaveText("2026년 10월 · 10월 21일까지"); + await expect(tile(page, "해낸 할 일")).toHaveText("0 / 2"); + + await page.getByRole("button", { name: "이전 달" }).click(); + await expect(period(page)).toHaveText("2026년 9월"); + await expect(tile(page, "해낸 할 일")).toHaveText("0 / 1"); + + await page.getByRole("button", { name: "다음 달" }).click(); + await page.getByRole("button", { name: "다음 달" }).click(); + await expect(period(page)).toHaveText("2026년 11월"); + await expect(page.getByText("아직 오지 않은 기간이에요.")).toBeVisible(); + + await page.getByRole("button", { name: "이번 달" }).click(); + await expect(period(page)).toHaveText("2026년 10월 · 10월 21일까지"); + }); + + test("TC-RECORD-008 월간에서 옮긴 달은 주간으로 바꿔도 이어진다", async ({ page, signIn }) => { + await signIn(); + await page.goto("/record"); + await page.getByRole("button", { name: "이전 달" }).click(); + await expect(period(page)).toHaveText("2026년 9월"); + // 달을 옮기면 1일로 맞춰지므로 9월 1일이 속한 주가 나온다 (의도한 동작) + await weekMode(page); + await expect(period(page)).toHaveText("8월 30일 ~ 9월 5일"); + }); + }); + + test.describe(() => { + test.use({ today: "2027-01-05" }); + + test("TC-RECORD-007 연도가 바뀌는 달로 이동한다", async ({ page, signIn }) => { + const { seed } = await signIn(); + const category = await seed.category({ name: "공부" }); + const task = await seed.task({ title: "연말 정리", category, date: "2026-12-31" }); + await seed.taskLog({ taskId: task.id, date: "2026-12-31", completed: true }); + await page.goto("/record"); + + await page.getByRole("button", { name: "이전 달" }).click(); + await expect(period(page)).toHaveText("2026년 12월"); + await expect(tile(page, "해낸 할 일")).toHaveText("1 / 1"); + + await weekMode(page); + await page.getByRole("button", { name: "이번 주" }).click(); + await expect(period(page)).toHaveText("1월 3일 ~ 1월 9일 · 1월 5일까지"); + }); + }); +}); + +test.describe("기록: 다른 화면에서 한 일", { tag: "@feature" }, () => { + test.use({ today: "2026-10-21" }); + + test("TC-RECORD-009 루틴 행을 누르면 체크한 날이 \"했음\" 으로 보인다", async ({ page, signIn }) => { + const { seed } = await signIn(); + const category = await seed.category({ name: "건강" }); + await seed.routine({ title: "독서", categoryId: category.id, orderIndex: 0, startDate: "2026-10-01" }); + const history = page.getByRole("dialog", { name: "독서" }); + + // 기록 창을 한 번 열어 그 기간의 기록을 캐시에 남긴다 + await page.goto("/record"); + await page.getByRole("button", { name: "독서 기록 보기", exact: true }).click(); + await expect(history.getByRole("img", { name: "10월 20일 못 함" })).toBeVisible(); + await page.keyboard.press("Escape"); + + await goTo(page, "홈"); + await page.getByRole("button", { name: "독서 10월 20일", exact: true }).click(); + await waitForSaved(page); + await goTo(page, "기록"); + await page.getByRole("button", { name: "독서 기록 보기", exact: true }).click(); + await expect(history.getByRole("img", { name: "10월 20일 했음" })).toBeVisible(); + }); + + test("TC-RECORD-010 홈에서 추가·삭제한 할 일이 반영된다", async ({ page, signIn }) => { + const { seed } = await signIn(); + const category = await seed.category({ name: "공부" }); + const done = await seed.task({ title: "C", category, date: "2026-10-21" }); + await seed.taskLog({ taskId: done.id, date: "2026-10-21", completed: true }); + await page.goto("/record"); + await expect(tile(page, "해낸 할 일")).toHaveText("1 / 1"); + + await goTo(page, "홈"); + await page.getByRole("button", { name: "공부에 할 일 추가" }).click(); + await page.getByLabel("할 일 입력").fill("D"); + await page.getByLabel("할 일 입력").press("Enter"); + await expect(page.getByRole("checkbox", { name: "D", exact: true })).toBeVisible(); + await waitForSaved(page); + await goTo(page, "기록"); + await expect(tile(page, "해낸 할 일")).toHaveText("1 / 2"); + await expect(section(page, "남은 할 일").getByRole("listitem")).toHaveText([/^오늘.*D$/]); + + await goTo(page, "홈"); + await page.getByRole("button", { name: "C 상세 보기", exact: true }).click(); + await page.getByRole("dialog", { name: "태스크 상세" }).getByRole("button", { name: "삭제", exact: true }).click(); + await page.getByRole("dialog", { name: "할 일을 삭제할까요?" }).getByRole("button", { name: "삭제" }).click(); + await expect(page.getByRole("checkbox", { name: "C", exact: true })).toHaveCount(0); + await waitForSaved(page); + await goTo(page, "기록"); + await expect(tile(page, "해낸 할 일")).toHaveText("0 / 1"); + await expect(page.getByText("이 기간에 해낸 할 일이 없어요.")).toBeVisible(); + }); + + test("TC-RECORD-011 홈에서 일괄 이동하면 반영된다", async ({ page, signIn }) => { + const { seed } = await signIn(); + const category = await seed.category({ name: "공부" }); + await seed.task({ title: "가", category, date: "2026-10-20", orderIndex: 0 }); + await seed.task({ title: "나", category, date: "2026-10-20", orderIndex: 1 }); + await page.goto("/record"); + const remaining = section(page, "남은 할 일").getByRole("listitem"); + await expect(remaining).toHaveText([/^10\/20.*가$/, /^10\/20.*나$/]); + + await goTo(page, "홈"); + const taskSection = page.getByRole("region", { name: "할 일 목록", exact: true }); + await taskSection.getByRole("button", { name: "이전" }).click(); + await expect(taskSection.getByText(/^2026년 10월 20일/)).toBeVisible(); + await page.getByRole("button", { name: "리스트 메뉴" }).click(); + const menu = page.getByRole("dialog", { name: "리스트 메뉴" }); + await menu.getByRole("radio", { name: "미완료 할 일을 오늘 하기" }).click(); + await menu.getByRole("button", { name: "완료" }).click(); + await expect(page.getByRole("checkbox", { name: "가" })).toHaveCount(0); + await waitForSaved(page); + + await goTo(page, "기록"); + await expect(remaining).toHaveText([/^오늘.*가$/, /^오늘.*나$/]); + }); +}); + +test.describe("기록: 목록과 빈 상태", { tag: "@feature" }, () => { + test.use({ today: "2026-10-21" }); + + test("TC-RECORD-012 남은 할 일이 10개를 넘으면 접고 펼친다", async ({ page, signIn }) => { + const { seed } = await signIn(); + const category = await seed.category({ name: "공부" }); + for (let day = 1; day <= 12; day++) { + const date = `2026-10-${String(day).padStart(2, "0")}`; + await seed.task({ title: `할 일 ${day}`, category, date }); + } + await page.goto("/record"); + const remaining = section(page, "남은 할 일").getByRole("listitem"); + await expect(remaining).toHaveCount(10); + await page.getByRole("button", { name: "2개 더 보기" }).click(); + await expect(remaining).toHaveCount(12); + await expect(remaining.last()).toContainText("할 일 12"); + await page.getByRole("button", { name: "접기" }).click(); + await expect(remaining).toHaveCount(10); + }); + + test("TC-RECORD-013 빈 사용자는 \"-\" 와 안내 문구를 본다", async ({ page, signIn }) => { + await signIn(); + await page.goto("/record"); + await expect(tile(page, "달성률")).toHaveText("-"); + await expect(page.getByText("이 기간에 해낸 할 일이 없어요.")).toBeVisible(); + await expect(page.getByText("이 기간의 할 일을 모두 해냈어요.")).toBeVisible(); + await expect(page.getByText("이 기간에 해야 했던 루틴이 없어요.")).toBeVisible(); + }); +}); diff --git a/e2e/specs/routine/routine-p2.spec.ts b/e2e/specs/routine/routine-p2.spec.ts new file mode 100644 index 0000000..756d902 --- /dev/null +++ b/e2e/specs/routine/routine-p2.spec.ts @@ -0,0 +1,266 @@ +import type { Page } from "@playwright/test"; +import { test, expect } from "../../fixtures"; +import { dragTo } from "../../drag"; +import { failWrites, waitForSaved } from "../../network"; + +// 10월 21일(수). 그 주는 10월 18일(일)~24일(토) +test.use({ today: "2026-10-21" }); + +const createDialog = (page: Page) => page.getByRole("dialog", { name: "루틴 추가" }); +const openCreate = async (page: Page) => { + await page.getByRole("button", { name: "추가", exact: true }).click(); + return createDialog(page); +}; +const routineNames = (page: Page) => + page.getByLabel(/ 기록 보기$/).evaluateAll((buttons) => + buttons.map((button) => button.getAttribute("aria-label")!.replace(/ 기록 보기$/, "")), + ); +const openDetail = async (page: Page, title: string) => { + await page.getByRole("button", { name: `${title} 상세 보기`, exact: true }).click(); + return page.getByRole("dialog", { name: "루틴 상세" }); +}; + +test.describe("루틴: 만들기", { tag: "@feature" }, () => { + test("TC-ROUTINE-005 이름이나 요일 없이 저장하면 알리고 만들지 않는다", async ({ page, signIn }) => { + const { seed } = await signIn(); + await seed.category({ name: "건강" }); + await page.goto("/routine"); + const dialog = await openCreate(page); + + await dialog.getByRole("button", { name: "월", exact: true }).click(); + await dialog.getByRole("button", { name: "저장" }).click(); + await expect(page.getByText("루틴 이름을 입력해 주세요.")).toBeVisible(); + + await dialog.getByRole("button", { name: "월", exact: true }).click(); + await dialog.getByLabel("루틴명").fill("독서"); + await dialog.getByRole("button", { name: "저장" }).click(); + await expect(page.getByText("반복할 요일을 하나 이상 선택해 주세요.")).toBeVisible(); + await expect(dialog).toBeVisible(); + expect(await routineNames(page)).toEqual([]); + }); + + test("TC-ROUTINE-006 시작일을 종료일보다 뒤로 옮기면 종료일이 따라간다", async ({ page, signIn }) => { + const { seed } = await signIn(); + await seed.category({ name: "건강" }); + await page.goto("/routine"); + const dialog = await openCreate(page); + + await dialog.getByRole("button", { name: "종료 날짜 추가" }).click(); + await dialog.getByRole("button", { name: /^종료 날짜 (선택|\d)/ }).click(); + await page.getByRole("button", { name: "2026년 10월 25일" }).click(); + await expect(dialog.getByRole("button", { name: "종료 날짜 2026. 10. 25." })).toBeVisible(); + + await dialog.getByRole("button", { name: /^시작 날짜 / }).click(); + await page.getByRole("button", { name: "2026년 10월 28일" }).click(); + await expect(dialog.getByRole("button", { name: "종료 날짜 2026. 10. 28." })).toBeVisible(); + + // 시작일보다 앞선 날은 종료일로 고를 수 없다 + await dialog.getByRole("button", { name: /^종료 날짜 (선택|\d)/ }).click(); + await expect(page.getByRole("button", { name: "2026년 10월 27일" })).toBeDisabled(); + await expect(page.getByRole("button", { name: "2026년 10월 28일" })).toBeEnabled(); + }); + + test("TC-ROUTINE-008 저장을 연타해도 루틴은 하나만 생긴다", async ({ page, signIn }) => { + const { seed } = await signIn(); + await seed.category({ name: "건강" }); + await page.goto("/routine"); + const dialog = await openCreate(page); + await dialog.getByRole("button", { name: "월", exact: true }).click(); + await dialog.getByLabel("루틴명").fill("독서"); + // 첫 Enter 로 모달이 닫혀도 이어 누른 키가 저장을 한 번 더 부르지 않아야 한다 + await page.keyboard.press("Enter"); + await page.keyboard.press("Enter"); + await page.keyboard.press("Enter"); + + await expect(page.getByRole("dialog")).toHaveCount(0); + await waitForSaved(page); + await page.reload(); + await expect(page.getByRole("button", { name: "독서 기록 보기", exact: true })).toHaveCount(1); + }); + + test("TC-ROUTINE-014 카테고리가 없으면 만들러 갈 수 있다", async ({ page, signIn }) => { + await signIn(); + await page.goto("/routine"); + await expect(page.getByText("아직 카테고리가 없습니다.")).toBeVisible(); + await page.getByRole("link", { name: "카테고리 만들러 가기" }).click(); + await expect(page).toHaveURL(/\/category$/); + }); +}); + +test.describe("루틴: 고치기와 지우기", { tag: "@feature" }, () => { + test("TC-ROUTINE-009 종료일을 지난 날로 정하면 [종료] 가 붙는다", async ({ page, signIn }) => { + const { seed } = await signIn(); + const category = await seed.category({ name: "건강" }); + await seed.routine({ title: "독서", categoryId: category.id, orderIndex: 0, startDate: "2026-10-01" }); + await page.goto("/routine"); + + const detail = await openDetail(page, "독서"); + await detail.getByRole("button", { name: "수정" }).click(); + const edit = page.getByRole("dialog", { name: "루틴 수정" }); + await edit.getByRole("button", { name: "종료 날짜 추가" }).click(); + await edit.getByRole("button", { name: /^종료 날짜 (선택|\d)/ }).click(); + await page.getByRole("button", { name: "2026년 10월 20일" }).click(); + await edit.getByRole("button", { name: "저장" }).click(); + await expect(page.getByRole("dialog")).toHaveCount(0); + + await expect(page.getByRole("button", { name: "독서 기록 보기", exact: true })).toContainText("[종료]"); + await page.getByRole("button", { name: "독서 기록 보기", exact: true }).click(); + await expect(page.getByRole("dialog", { name: "독서" })).toContainText("10월 20일에 끝남"); + await page.keyboard.press("Escape"); + + await waitForSaved(page); + await page.getByRole("link", { name: "홈", exact: true }).click(); + await expect(page.getByRole("button", { name: "독서 10월 20일", exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "독서 10월 21일", exact: true })).toHaveCount(0); + }); + + test("TC-ROUTINE-010 삭제하면 루틴·홈·기록 모두에서 사라진다", async ({ page, signIn }) => { + const { seed } = await signIn(); + const category = await seed.category({ name: "건강" }); + await seed.routine({ title: "독서", categoryId: category.id, orderIndex: 0, startDate: "2026-10-01" }); + await seed.routine({ title: "산책", categoryId: category.id, orderIndex: 1, startDate: "2026-10-01" }); + // 홈과 기록을 먼저 열어 캐시에 남긴다 + await page.goto("/home"); + await expect(page.getByRole("button", { name: "독서 10월 21일", exact: true })).toBeVisible(); + await page.getByRole("link", { name: "기록", exact: true }).click(); + await expect(page.getByRole("button", { name: "독서 기록 보기", exact: true })).toBeVisible(); + await page.getByRole("link", { name: "루틴", exact: true }).click(); + await expect(page.getByRole("heading", { name: "건강" })).toBeVisible(); + + const detail = await openDetail(page, "독서"); + const confirm = page.getByRole("dialog", { name: "루틴을 삭제할까요?" }); + await detail.getByRole("button", { name: "삭제", exact: true }).click(); + await expect(confirm).toContainText("수행 기록도 함께 삭제"); + await confirm.getByRole("button", { name: "취소" }).click(); + await expect(detail).toBeVisible(); + await detail.getByRole("button", { name: "삭제", exact: true }).click(); + await confirm.getByRole("button", { name: "삭제" }).click(); + await expect(page.getByRole("dialog")).toHaveCount(0); + expect(await routineNames(page)).toEqual(["산책"]); + + await waitForSaved(page); + await page.getByRole("link", { name: "홈", exact: true }).click(); + await expect(page.getByRole("button", { name: "산책 10월 21일", exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "독서 10월 21일", exact: true })).toHaveCount(0); + await page.getByRole("link", { name: "기록", exact: true }).click(); + await expect(page.getByRole("button", { name: "산책 기록 보기", exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "독서 기록 보기", exact: true })).toHaveCount(0); + }); + + test("TC-ROUTINE-011 수정 중에 닫으려 하면 확인을 묻는다", async ({ page, signIn }) => { + const { seed } = await signIn(); + const category = await seed.category({ name: "건강" }); + await seed.routine({ title: "독서", categoryId: category.id, orderIndex: 0, startDate: "2026-10-01" }); + await page.goto("/routine"); + const guard = page.getByRole("dialog", { name: "수정 중인 내용이 있습니다" }); + + // 작성 중 Esc → 버리면 모달이 닫힌다 + const create = await openCreate(page); + await create.getByLabel("루틴명").fill("새 루틴"); + await page.keyboard.press("Escape"); + await guard.getByRole("button", { name: "닫기" }).click(); + await expect(page.getByRole("dialog")).toHaveCount(0); + + // 상세에서 들어간 수정은 고친 게 없으면 저장이 막혀 있고, 버리면 상세로 돌아간다 + const detail = await openDetail(page, "독서"); + await detail.getByRole("button", { name: "수정" }).click(); + const edit = page.getByRole("dialog", { name: "루틴 수정" }); + await expect(edit.getByRole("button", { name: "저장" })).toBeDisabled(); + await edit.getByLabel("루틴명").fill("독서 30분"); + await page.mouse.click(5, 5); + await guard.getByRole("button", { name: "닫기" }).click(); + await expect(detail).toBeVisible(); + await expect(detail.getByLabel("루틴명")).toHaveValue("독서"); + }); + + test("TC-ROUTINE-012 요일을 그대로 두고 수정하면 시작일이 잠긴다", async ({ page, signIn }) => { + const { seed } = await signIn(); + const category = await seed.category({ name: "건강" }); + await seed.routine({ title: "독서", categoryId: category.id, orderIndex: 0, startDate: "2026-10-01" }); + await page.goto("/routine"); + const detail = await openDetail(page, "독서"); + await detail.getByRole("button", { name: "수정" }).click(); + const edit = page.getByRole("dialog", { name: "루틴 수정" }); + + await expect(edit.getByText("2026-10-01")).toBeVisible(); + await expect(edit.getByRole("button", { name: /^시작 날짜/ })).toHaveCount(0); + + await edit.getByRole("button", { name: "토", exact: true }).click(); + const effective = edit.getByRole("button", { name: /^변경 적용 날짜/ }); + await expect(effective).toBeVisible(); + await effective.click(); + // 루틴이 시작하기 전부터 새 요일을 적용할 수는 없다 + await expect(page.getByRole("button", { name: "2026년 9월 30일" })).toBeDisabled(); + // Esc 는 선택기만 닫고 수정 모달은 그대로 둔다 + await page.keyboard.press("Escape"); + await expect(page.getByRole("button", { name: "2026년 9월 30일" })).toHaveCount(0); + await expect(page.getByRole("dialog", { name: "수정 중인 내용이 있습니다" })).toHaveCount(0); + await expect(edit).toBeVisible(); + + await edit.getByRole("button", { name: "토", exact: true }).click(); + await expect(effective).toHaveCount(0); + await expect(edit.getByText("2026-10-01")).toBeVisible(); + }); + + test("TC-ROUTINE-013 기록 창에서 기간을 바꾸고 루틴 정보로 간다", async ({ page, signIn }) => { + const { seed } = await signIn(); + const category = await seed.category({ name: "건강" }); + await seed.routine({ title: "독서", categoryId: category.id, orderIndex: 0, startDate: "2026-10-15" }); + await page.goto("/routine"); + await page.getByRole("button", { name: "독서 기록 보기", exact: true }).click(); + const history = page.getByRole("dialog", { name: "독서" }); + + // 15일~21일 7번 중 0번 + await expect(history).toContainText(/해야 했던 7번 중\s*0번\s*했어요/); + await expect(history.getByRole("img", { name: "10월 15일 못 함" })).toBeVisible(); + await expect(history.getByRole("img", { name: /^10월 14일/ })).toHaveCount(0); + + await history.getByRole("button", { name: "3개월" }).click(); + await expect(history.getByRole("img", { name: "10월 15일 못 함" })).toBeVisible(); + + await history.getByRole("button", { name: "루틴 정보" }).click(); + await expect(page.getByRole("dialog", { name: "루틴 상세" })).toBeVisible(); + }); +}); + +test.describe("루틴: 실패", { tag: "@feature" }, () => { + test.use({ expectedError: /status of 500|Internal Server Error/ }); + + test("TC-ROUTINE-007 저장에 실패하면 알리고 입력이 남는다", async ({ page, signIn }) => { + const { seed } = await signIn(); + await seed.category({ name: "건강" }); + await page.goto("/routine"); + await expect(page.getByRole("heading", { name: "건강" })).toBeVisible(); + await waitForSaved(page); + await failWrites(page); + + const dialog = await openCreate(page); + await dialog.getByLabel("루틴명").fill("독서"); + await dialog.getByRole("button", { name: "월", exact: true }).click(); + await dialog.getByRole("button", { name: "저장" }).click(); + await expect(page.getByText("루틴 저장에 실패했습니다. 잠시 후 다시 시도해 주세요.")).toBeVisible(); + await expect(dialog.getByLabel("루틴명")).toHaveValue("독서"); + await expect(dialog.getByRole("button", { name: "월", pressed: true })).toBeVisible(); + await expect(dialog.getByRole("button", { name: "저장" })).toBeEnabled(); + }); + + test( + "TC-ROUTINE-015 순서 저장에 실패하면 서버 순서로 돌아온다", + { tag: "@regression", annotation: { type: "출처", description: "58f466b" } }, + async ({ page, signIn }) => { + const { seed } = await signIn(); + const category = await seed.category({ name: "건강" }); + for (const [orderIndex, title] of ["A", "B", "C"].entries()) { + await seed.routine({ title, categoryId: category.id, orderIndex, startDate: "2026-10-01" }); + } + await page.goto("/routine"); + await expect.poll(() => routineNames(page)).toEqual(["A", "B", "C"]); + await waitForSaved(page); + await failWrites(page); + + await dragTo(page, page.getByLabel("C 기록 보기"), page.getByLabel("A 기록 보기")); + await expect(page.getByText("순서 저장에 실패했습니다. 잠시 후 다시 시도해 주세요.")).toBeVisible(); + await expect.poll(() => routineNames(page)).toEqual(["A", "B", "C"]); + }, + ); +}); diff --git a/eslint.config.js b/eslint.config.js index a242db2..24bce82 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -135,7 +135,7 @@ export default defineConfig([ { // Playwright 테스트는 React 코드가 아니다. fixture 가 넘기는 use() 를 훅으로 잘못 읽으므로 // React 규칙만 끄고, Node 에서 돌므로 Node 전역을 연다. - files: ['e2e/**/*.ts', 'playwright.config.ts'], + files: ['e2e/**/*.ts', 'playwright.config.ts', 'playwright.record.config.ts'], languageOptions: { globals: globals.node }, rules: { 'react-hooks/rules-of-hooks': 'off', diff --git a/package.json b/package.json index 72da758..811452a 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "test": "vitest run", "test:rules": "firebase emulators:exec --only firestore \"vitest run --config vitest.rules.config.ts\"", "e2e": "firebase emulators:exec --only auth,firestore --project demo-checky \"playwright test\"", - "e2e:ui": "firebase emulators:exec --only auth,firestore --project demo-checky \"playwright test --ui\"" + "e2e:ui": "firebase emulators:exec --only auth,firestore --project demo-checky \"playwright test --ui\"", + "e2e:record": "firebase emulators:exec --only auth,firestore --project demo-checky \"playwright test -c playwright.record.config.ts\"" }, "dependencies": { "@dnd-kit/core": "^6.3.1", diff --git a/playwright.record.config.ts b/playwright.record.config.ts new file mode 100644 index 0000000..5496d35 --- /dev/null +++ b/playwright.record.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "@playwright/test"; +import base from "./playwright.config"; + +/** + * 클릭으로 테스트를 만드는 녹화 모드 설정. `npm run e2e:record` 가 쓴다. + * 평소 테스트와 같은 앱·에뮬레이터·로그인을 쓰고, 브라우저를 띄운 채 끝없이 기다린다. + */ +export default defineConfig({ + ...base, + testDir: "./e2e/record", + timeout: 0, + retries: 0, + workers: 1, + reporter: "list", + projects: [{ name: "record", use: { ...base.projects?.[0]?.use, headless: false } }], +}); diff --git a/src/pages/HomePage/components/AchievementReport/index.tsx b/src/pages/HomePage/components/AchievementReport/index.tsx index d46634c..264ea09 100644 --- a/src/pages/HomePage/components/AchievementReport/index.tsx +++ b/src/pages/HomePage/components/AchievementReport/index.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { useMemo, useState, useId } from "react"; import TitleSection from "../TitleSection"; import { useSelectedDate } from "@/shared/contexts/useSelectedDate"; import { useMonthlyStatsByMonths } from "@/shared/hooks/calendar"; @@ -74,9 +74,13 @@ function AchievementReportSection() { mode === "week" ? moveWeek(selectedDate, diff) : moveMonth(selectedDate, diff), ); + // 섹션 전체가 제목을 이름으로 갖는 영역이 된다. TitleSection 이 같은 id 를 제목에 단다 + const titleId = useId(); + return ( -
+
move(-1)} @@ -96,7 +100,7 @@ function AchievementReportSection() { todayYmd={todayYmd} /> )} -
+ ); } diff --git a/src/pages/HomePage/components/MonthlyReport/components/MonthlyReportTitleSection.tsx b/src/pages/HomePage/components/MonthlyReport/components/MonthlyReportTitleSection.tsx index 94b6874..0562ae9 100644 --- a/src/pages/HomePage/components/MonthlyReport/components/MonthlyReportTitleSection.tsx +++ b/src/pages/HomePage/components/MonthlyReport/components/MonthlyReportTitleSection.tsx @@ -9,14 +9,18 @@ import { useSelectedDate } from "@/shared/contexts/useSelectedDate"; */ export function MonthlyReportTitleSection({ refresh, + titleId, }: { refresh: () => Promise; + /** 캘린더 섹션이 이름으로 거는 제목 id */ + titleId?: string; }) { const { selectedDate, setSelectedDate } = useSelectedDate(); // 캘린더 헤더에 표시할 월 정보 생성 const label = getMonthLabel(selectedDate); return ( setSelectedDate(moveMonth(selectedDate, -1))} diff --git a/src/pages/HomePage/components/MonthlyReport/index.tsx b/src/pages/HomePage/components/MonthlyReport/index.tsx index a04094f..328e7c3 100644 --- a/src/pages/HomePage/components/MonthlyReport/index.tsx +++ b/src/pages/HomePage/components/MonthlyReport/index.tsx @@ -1,3 +1,4 @@ +import { useId } from "react"; import { useSelectedDate } from "@/shared/contexts/useSelectedDate"; import { useMonthlyData } from "@/shared/hooks/calendar"; import { CalendarBody } from "./components/CalendarBody"; @@ -18,10 +19,13 @@ function MonthlyReportSection() { const { selectedDate } = useSelectedDate(); const monthlyData = useMonthlyData(selectedDate); + // 섹션 전체가 제목을 이름으로 갖는 영역이 된다. TitleSection 이 같은 id 를 제목에 단다 + const titleId = useId(); + return ( -
+
{/* 1. 타이틀 섹션 */} - + {/* 2. 캘린더 섹션 */} {monthlyData.isLoading ? ( @@ -32,7 +36,7 @@ function MonthlyReportSection() {
)} - + ); } diff --git a/src/pages/HomePage/components/RoutineReport/index.tsx b/src/pages/HomePage/components/RoutineReport/index.tsx index 5e96d8d..b73bc6f 100644 --- a/src/pages/HomePage/components/RoutineReport/index.tsx +++ b/src/pages/HomePage/components/RoutineReport/index.tsx @@ -1,3 +1,4 @@ +import { useId } from "react"; import TitleSection from "../TitleSection"; import { useSelectedDate } from "@/shared/contexts/useSelectedDate"; import { moveWeek } from "@/shared/utils/dateNavigation"; @@ -39,10 +40,14 @@ function RoutineReportSection() { week, }); + // 섹션 전체가 제목을 이름으로 갖는 영역이 된다. TitleSection 이 같은 id 를 제목에 단다 + const titleId = useId(); + return ( -
+
{/* 1. 타이틀 */} setSelectedDate(moveWeek(selectedDate, -1))} @@ -69,7 +74,7 @@ function RoutineReportSection() { // 2-3. 테이블 )} -
+ ); } diff --git a/src/pages/HomePage/components/TaskReport/modals/TaskSettingModal.tsx b/src/pages/HomePage/components/TaskReport/modals/TaskSettingModal.tsx index b050b51..5ea3a46 100644 --- a/src/pages/HomePage/components/TaskReport/modals/TaskSettingModal.tsx +++ b/src/pages/HomePage/components/TaskReport/modals/TaskSettingModal.tsx @@ -53,7 +53,7 @@ interface ActionItemListProps { } const ActionItemList = ({ selectedAction, onSelect }: ActionItemListProps) => { return ( - + {TASK_ACTION_LIST.map((action) => ( { }; return ( -
+ // 줄 전체가 하나의 선택지다. 오른쪽 동그라미만 버튼이면 이름도 선택 상태도 없어 무엇을 고르는지 알 수 없다 +
+ ); }; diff --git a/src/pages/HomePage/components/TaskReport/sections/TaskReportSection.tsx b/src/pages/HomePage/components/TaskReport/sections/TaskReportSection.tsx index 000fbaf..70e76ce 100644 --- a/src/pages/HomePage/components/TaskReport/sections/TaskReportSection.tsx +++ b/src/pages/HomePage/components/TaskReport/sections/TaskReportSection.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { useMemo, useState, useId } from "react"; import TitleSection from "../../TitleSection"; import { useAuth } from "@/shared/hooks/useAuth"; import { useSelectedDate } from "@/shared/contexts/useSelectedDate"; @@ -86,9 +86,13 @@ function TaskReportSection() { isWeek ? moveWeek(selectedDate, diff) : moveDay(selectedDate, diff), ); + // 섹션 전체가 제목을 이름으로 갖는 영역이 된다. TitleSection 이 같은 id 를 제목에 단다 + const titleId = useId(); + return ( -
+
move(-1)} @@ -126,7 +130,7 @@ function TaskReportSection() {
)} - + ); } diff --git a/src/pages/HomePage/components/TitleSection.tsx b/src/pages/HomePage/components/TitleSection.tsx index a06b6d1..a8433a2 100644 --- a/src/pages/HomePage/components/TitleSection.tsx +++ b/src/pages/HomePage/components/TitleSection.tsx @@ -13,6 +13,12 @@ interface TitleSectionProps { onRefreshClick?: () => void; /** 버튼 앞에 두는 섹션 전용 컨트롤 (기간 전환 등) */ extra?: ReactNode; + /** + * 제목에 붙일 id. 섹션 전체를 감싼 쪽이 이 id 로 섹션 이름을 건다. + * 홈의 섹션 넷이 "오늘"·"새로고침"·이전·다음을 같은 이름으로 가지므로, 섹션 이름으로 + * 어느 섹션의 버튼인지 스크린 리더와 테스트가 구분한다 + */ + titleId?: string; } function TitleSection({ title, @@ -22,13 +28,14 @@ function TitleSection({ onTodayClick, onRefreshClick, extra, + titleId, }: TitleSectionProps) { return (
{/* 좁은 화면에서 버튼이 많아지면(할 일 목록의 보기 전환 등) 제목을 밀어내지 않고 다음 줄로 내린다 */}
- +
@@ -53,12 +60,12 @@ function TitleSection({ )} -
+
-
+ +
+
diff --git a/src/pages/LandingPage/hooks/useFilmEngine.ts b/src/pages/LandingPage/hooks/useFilmEngine.ts index f90cad3..3d890aa 100644 --- a/src/pages/LandingPage/hooks/useFilmEngine.ts +++ b/src/pages/LandingPage/hooks/useFilmEngine.ts @@ -203,8 +203,10 @@ export function useFilmEngine(refs: FilmEngineRefs) { effects(time); }; + // 무대가 아직 자리를 잡지 않았거나 화면을 떠나는 중이면 높이가 0 이라 0/0 = NaN 이 된다. + // NaN 이 배우의 좌표로 흘러 들어가지 않도록 그때는 첫 장면에 둔다 const readTarget = () => - clamp((window.scrollY - top) / unit, 0, TIMELINE_LENGTH); + unit > 0 ? clamp((window.scrollY - top) / unit, 0, TIMELINE_LENGTH) : 0; const layout = () => { const size = { w: stage.clientWidth, h: stage.clientHeight }; diff --git a/src/pages/MyPage/components/MonthlyHistorySection.tsx b/src/pages/MyPage/components/MonthlyHistorySection.tsx index 7de558b..4ac1189 100644 --- a/src/pages/MyPage/components/MonthlyHistorySection.tsx +++ b/src/pages/MyPage/components/MonthlyHistorySection.tsx @@ -69,9 +69,10 @@ export const MonthlyHistorySection = () => { ))}
) : ( - + // 달마다 한 줄인 목록이다. 줄 단위로 읽혀야 "9월 70%" 가 한 덩어리로 전달된다 + {rows.map((row) => ( -
@@ -100,7 +101,7 @@ export const MonthlyHistorySection = () => { )} -
+ ))}
)} diff --git a/src/pages/RoutinePage/components/RoutineList/components/RoutineModal/components/EndDateField.tsx b/src/pages/RoutinePage/components/RoutineList/components/RoutineModal/components/EndDateField.tsx index aaee90e..20d1406 100644 --- a/src/pages/RoutinePage/components/RoutineList/components/RoutineModal/components/EndDateField.tsx +++ b/src/pages/RoutinePage/components/RoutineList/components/RoutineModal/components/EndDateField.tsx @@ -31,6 +31,8 @@ export const EndDateField = ({ ))} ); diff --git a/tsconfig.e2e.json b/tsconfig.e2e.json index 4e8a19a..2aeca01 100644 --- a/tsconfig.e2e.json +++ b/tsconfig.e2e.json @@ -22,5 +22,5 @@ "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true }, - "include": ["playwright.config.ts", "e2e"] + "include": ["playwright.config.ts", "playwright.record.config.ts", "e2e"] }