From 35b4e3b549e634427feb22427b17325b3f41b111 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B4=91=EC=9D=BC?= <54162245+pangil5634@users.noreply.github.com> Date: Tue, 27 Oct 2026 22:16:18 +0900 Subject: [PATCH 01/13] feat: name home sections, list menu options, end date toggle and notices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 홈의 네 섹션이 제목을 이름으로 갖는 영역이 되어, 같은 이름의 오늘·새로고침·이전·다음 버튼을 섹션으로 구분할 수 있습니다. 클릭만 되던 이전·다음 화살표와 공지 항목을 버튼으로 바꿨습니다. 리스트 메뉴는 줄 전체가 하나의 선택지(radio)가 되어 선택 상태를 알 수 있습니다. 루틴 종료 날짜 토글은 "종료 날짜 추가"처럼 다른 추가·삭제 버튼과 겹치지 않는 이름을 갖습니다. --- .../TaskReport/modals/TaskSettingModal.tsx | 19 +++++++++++-------- .../HomePage/components/TitleSection.tsx | 19 +++++++++++-------- .../RoutineModal/components/EndDateField.tsx | 2 ++ src/shared/ui/TitleText.tsx | 6 ++++-- .../ui/notices/components/NoticeList.tsx | 7 ++++--- 5 files changed, 32 insertions(+), 21 deletions(-) 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/TitleSection.tsx b/src/pages/HomePage/components/TitleSection.tsx index a06b6d1..3ca2b93 100644 --- a/src/pages/HomePage/components/TitleSection.tsx +++ b/src/pages/HomePage/components/TitleSection.tsx @@ -1,4 +1,4 @@ -import type { ReactNode } from "react"; +import { useId, type ReactNode } from "react"; import { Stack, Text } from "@/shared/ui/primitives"; import { TitleText } from "@/shared/ui/TitleText"; import { VscTriangleLeft } from "react-icons/vsc"; @@ -23,12 +23,15 @@ function TitleSection({ onRefreshClick, extra, }: TitleSectionProps) { + // 홈의 섹션 넷이 "오늘"·"새로고침"·이전·다음을 같은 이름으로 가진다. 제목을 섹션 이름으로 걸어 + // 어느 섹션의 버튼인지 스크린 리더와 테스트가 구분할 수 있게 한다 + const titleId = useId(); return ( -
+
{/* 좁은 화면에서 버튼이 많아지면(할 일 목록의 보기 전환 등) 제목을 밀어내지 않고 다음 줄로 내린다 */}
- +
@@ -53,15 +56,15 @@ function TitleSection({ )} -
+
-
+ +
+
-
+ ); } 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..b8bac71 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 = ({ ))} ); From 17fe3402b4073a963769b9f4a51f46cfc1d3d8a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B4=91=EC=9D=BC?= <54162245+pangil5634@users.noreply.github.com> Date: Tue, 27 Oct 2026 22:16:18 +0900 Subject: [PATCH 02/13] test: extend e2e seeds for ended categories, routine history, stats and notices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 종료한 카테고리, 루틴 종료일과 요일 변경 이력, 할 일 시간, 월간 통계 문서, 공지를 시드로 넣을 수 있게 했습니다. 공지는 모든 사용자가 함께 보는 컬렉션이라 사용자 시드와 따로 둡니다. --- e2e/TODO.md | 21 ---------- e2e/seed.ts | 113 +++++++++++++++++++++++++++++++++++++++++++--------- 2 files changed, 95 insertions(+), 39 deletions(-) diff --git a/e2e/TODO.md b/e2e/TODO.md index 643b8a1..3a140db 100644 --- a/e2e/TODO.md +++ b/e2e/TODO.md @@ -2,27 +2,6 @@ 케이스 후보를 뽑으며 찾은, 테스트를 막는 지점이다. 해결하면 지운다. -## 마크업에 이름 붙이기 - -역할이나 이름이 없어 테스트가 사람처럼 찾을 수 없는 요소. 스크린 리더 사용자도 같은 이유로 쓰기 어렵다. -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 에서 한 번 관찰했다. 체크가 끝났을 때 그사이 통계를 다시 받아 왔으면 한 번 더 받아 오는 식으로 막을 수 있으나, 재현을 고정하는 테스트를 먼저 만들어야 한다. 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, From 015b1a81f0c3259a1ddf2045a8d216df7d34ce7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B4=91=EC=9D=BC?= <54162245+pangil5634@users.noreply.github.com> Date: Tue, 27 Oct 2026 22:20:09 +0900 Subject: [PATCH 03/13] test: add category p2 and p3 e2e cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 종료 뒤 새 카테고리의 순서, 복구, 이름 검증, 연타 방지, 수정 중 닫기 확인, 저장·순서 저장 실패, 빈 상태와 목록 읽기 실패를 확인하는 케이스를 추가했습니다. 목록 조회 요청은 users/{uid}:runQuery 로 나가므로 network.ts 의 설명을 바로잡았습니다. --- e2e/cases/category.md | 20 +-- e2e/network.ts | 2 +- e2e/specs/category/category-p2.spec.ts | 222 +++++++++++++++++++++++++ 3 files changed, 233 insertions(+), 11 deletions(-) create mode 100644 e2e/specs/category/category-p2.spec.ts 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/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/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(); + }); + }); +}); From c6a5b7ed2059fdced44375a6858ef835b1934728 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B4=91=EC=9D=BC?= <54162245+pangil5634@users.noreply.github.com> Date: Tue, 27 Oct 2026 22:29:35 +0900 Subject: [PATCH 04/13] fix: make each home section a labelled region from title to content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 앞서 섹션 이름을 머리줄(제목과 버튼)에만 걸어, 캘린더 칸이나 루틴 표 같은 본문이 그 영역 밖에 있었습니다. 네 섹션의 최상위 요소를 제목을 이름으로 갖는 section 으로 바꾸고, TitleSection 은 제목에 id 만 답니다. --- .../components/AchievementReport/index.tsx | 10 +++++++--- .../components/MonthlyReportTitleSection.tsx | 4 ++++ .../HomePage/components/MonthlyReport/index.tsx | 10 +++++++--- .../HomePage/components/RoutineReport/index.tsx | 9 +++++++-- .../TaskReport/sections/TaskReportSection.tsx | 10 +++++++--- src/pages/HomePage/components/TitleSection.tsx | 16 ++++++++++------ 6 files changed, 42 insertions(+), 17 deletions(-) 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/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 3ca2b93..a8433a2 100644 --- a/src/pages/HomePage/components/TitleSection.tsx +++ b/src/pages/HomePage/components/TitleSection.tsx @@ -1,4 +1,4 @@ -import { useId, type ReactNode } from "react"; +import type { ReactNode } from "react"; import { Stack, Text } from "@/shared/ui/primitives"; import { TitleText } from "@/shared/ui/TitleText"; import { VscTriangleLeft } from "react-icons/vsc"; @@ -13,6 +13,12 @@ interface TitleSectionProps { onRefreshClick?: () => void; /** 버튼 앞에 두는 섹션 전용 컨트롤 (기간 전환 등) */ extra?: ReactNode; + /** + * 제목에 붙일 id. 섹션 전체를 감싼 쪽이 이 id 로 섹션 이름을 건다. + * 홈의 섹션 넷이 "오늘"·"새로고침"·이전·다음을 같은 이름으로 가지므로, 섹션 이름으로 + * 어느 섹션의 버튼인지 스크린 리더와 테스트가 구분한다 + */ + titleId?: string; } function TitleSection({ title, @@ -22,12 +28,10 @@ function TitleSection({ onTodayClick, onRefreshClick, extra, + titleId, }: TitleSectionProps) { - // 홈의 섹션 넷이 "오늘"·"새로고침"·이전·다음을 같은 이름으로 가진다. 제목을 섹션 이름으로 걸어 - // 어느 섹션의 버튼인지 스크린 리더와 테스트가 구분할 수 있게 한다 - const titleId = useId(); return ( -
+
{/* 좁은 화면에서 버튼이 많아지면(할 일 목록의 보기 전환 등) 제목을 밀어내지 않고 다음 줄로 내린다 */}
@@ -64,7 +68,7 @@ function TitleSection({
-
+ ); } From 2919cd83cfd9a296350d34ca6aa5c33e88eb1b4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B4=91=EC=9D=BC?= <54162245+pangil5634@users.noreply.github.com> Date: Tue, 27 Oct 2026 22:29:35 +0900 Subject: [PATCH 05/13] test: add home p2 and p3 e2e cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이동으로 날짜 옮기기, 리스트 메뉴의 오늘 하기와 모든 할 일 삭제, 한 주 보기 끌기와 그 실패, 루틴 표 체크, 캘린더 날짜 선택, 빈 상태, 루틴 목록 읽기 실패 케이스를 추가했습니다. 끌기 도우미는 정해 둔 시간을 기다리는 대신 끌기가 실제로 시작될 때까지 기다려, 부하가 클 때 끌기가 취소되지 않습니다. --- e2e/README.md | 2 +- e2e/cases/home.md | 22 ++-- e2e/drag.ts | 9 +- e2e/specs/home/home-p2.spec.ts | 202 +++++++++++++++++++++++++++++++++ 4 files changed, 220 insertions(+), 15 deletions(-) create mode 100644 e2e/specs/home/home-p2.spec.ts diff --git a/e2e/README.md b/e2e/README.md index 97c9870..7a7f3b3 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -78,7 +78,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/cases/home.md b/e2e/cases/home.md index 1e192f4..e5dc3ee 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 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/specs/home/home-p2.spec.ts b/e2e/specs/home/home-p2.spec.ts new file mode 100644 index 0000000..1df3017 --- /dev/null +++ b/e2e/specs/home/home-p2.spec.ts @@ -0,0 +1,202 @@ +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-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(); + }); +}); From d7988416a03db4d30d67a03418bec9f113675636 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B4=91=EC=9D=BC?= <54162245+pangil5634@users.noreply.github.com> Date: Tue, 27 Oct 2026 22:35:32 +0900 Subject: [PATCH 06/13] fix: close only the date picker on escape inside a modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 모달 안의 날짜 선택기에서 Esc 를 누르면 선택기와 함께 모달도 Esc 를 받아, 고친 게 있으면 수정 확인이 뜨고 없으면 모달이 닫혔습니다. 선택기가 열려 있을 때의 Esc 는 선택기만 닫고 모달로 전달하지 않습니다. 한 모달에 선택기가 둘이면 구분되지 않아, 루틴의 시작·변경 적용·종료 날짜 선택기에 "시작 날짜 2026. 10. 28." 처럼 지금 값과 함께 이름을 붙였습니다. --- .../RoutineModal/components/EndDateField.tsx | 2 +- .../RoutineModal/components/StartDateField.tsx | 2 ++ src/shared/ui/DatePicker.tsx | 13 ++++++++++++- 3 files changed, 15 insertions(+), 2 deletions(-) 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 b8bac71..20d1406 100644 --- a/src/pages/RoutinePage/components/RoutineList/components/RoutineModal/components/EndDateField.tsx +++ b/src/pages/RoutinePage/components/RoutineList/components/RoutineModal/components/EndDateField.tsx @@ -48,7 +48,7 @@ export const EndDateField = ({ {routineEndDate ? routineEndDate : "없음"} ) : endDateEnabled ? ( - + ) : ( 없음 diff --git a/src/pages/RoutinePage/components/RoutineList/components/RoutineModal/components/StartDateField.tsx b/src/pages/RoutinePage/components/RoutineList/components/RoutineModal/components/StartDateField.tsx index 715b87a..f10ce80 100644 --- a/src/pages/RoutinePage/components/RoutineList/components/RoutineModal/components/StartDateField.tsx +++ b/src/pages/RoutinePage/components/RoutineList/components/RoutineModal/components/StartDateField.tsx @@ -83,6 +83,7 @@ export const StartDateField = ({ {fieldMode === "PICK_EFFECTIVE_FROM" && ( { setStartDate(next); diff --git a/src/shared/ui/DatePicker.tsx b/src/shared/ui/DatePicker.tsx index b3b5fe0..5cc17c0 100644 --- a/src/shared/ui/DatePicker.tsx +++ b/src/shared/ui/DatePicker.tsx @@ -29,6 +29,11 @@ interface DatePickerProps { /** 팝오버가 트리거 기준 어느 쪽으로 펼쳐질지 */ align?: "left" | "right"; className?: string; + /** + * 무엇의 날짜인지 ("시작 날짜" 등). 버튼 글자가 날짜뿐이라 한 화면에 선택기가 둘이면 구분되지 않는다. + * 넘기면 버튼 이름이 "시작 날짜 2026. 10. 28." 처럼 지금 값과 함께 붙는다 + */ + label?: string; } /** @@ -43,6 +48,7 @@ export const DatePicker = ({ disabled = false, align = "left", className = "", + label, }: DatePickerProps) => { const [open, setOpen] = useState(false); const triggerRef = useRef(null); @@ -73,7 +79,11 @@ export const DatePicker = ({ }; const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") setOpen(false); + if (event.key !== "Escape") return; + // 모달 안의 선택기에서 Esc 는 선택기만 닫는다. 모달도 Esc 를 window 에서 듣기 때문에 + // 여기서 멈추지 않으면 모달까지 닫히거나 "수정 중인 내용" 확인이 뜬다 + event.stopPropagation(); + setOpen(false); }; document.addEventListener("mousedown", handleClickOutside); @@ -109,6 +119,7 @@ export const DatePicker = ({ type="button" disabled={disabled} onClick={toggleOpen} + aria-label={label ? (value ? `${label} ${formatYmdLabel(value)}` : `${label} 선택`) : undefined} className={`border-b border-content-subtle text-sm text-left pb-0.5 min-w-28 ${disabled ? "opacity-40 cursor-not-allowed" : "cursor-pointer"} ${value ? "" : "text-content-muted"}`} From 4b78ce8d7c34d9e7b5b64f73383f00854b99ea5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B4=91=EC=9D=BC?= <54162245+pangil5634@users.noreply.github.com> Date: Tue, 27 Oct 2026 22:35:32 +0900 Subject: [PATCH 07/13] test: add routine p2 and p3 e2e cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 입력 검증, 시작일에 따라 밀리는 종료일, 저장 실패와 연타 방지, 지난 날로 종료, 확인을 거친 삭제의 화면 간 반영, 수정 중 닫기 확인, 요일을 그대로 둔 수정의 시작일 잠금, 기록 창, 빈 상태, 순서 저장 실패 케이스를 추가했습니다. --- e2e/cases/routine.md | 23 +-- e2e/specs/routine/routine-p2.spec.ts | 266 +++++++++++++++++++++++++++ 2 files changed, 278 insertions(+), 11 deletions(-) create mode 100644 e2e/specs/routine/routine-p2.spec.ts 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/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"]); + }, + ); +}); From 4643558cf2ced9635dceccea0ecd0648e8a467f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B4=91=EC=9D=BC?= <54162245+pangil5634@users.noreply.github.com> Date: Tue, 27 Oct 2026 22:37:27 +0900 Subject: [PATCH 08/13] test: add record p2 and p3 e2e cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기간 이동과 미래 기간, 연도가 바뀌는 달, 월간에서 주간으로 바꿀 때의 기준 날짜, 루틴 기록 창의 캐시 갱신, 홈에서 추가·삭제·일괄 이동한 할 일의 반영, 남은 할 일 접기, 빈 상태 케이스를 추가했습니다. --- e2e/cases/record.md | 16 +-- e2e/specs/record/record-p2.spec.ts | 180 +++++++++++++++++++++++++++++ 2 files changed, 188 insertions(+), 8 deletions(-) create mode 100644 e2e/specs/record/record-p2.spec.ts 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/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(); + }); +}); From 3e1f966e7917da4b793b1ed9d4dd5ad475000a92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B4=91=EC=9D=BC?= <54162245+pangil5634@users.noreply.github.com> Date: Tue, 27 Oct 2026 22:42:17 +0900 Subject: [PATCH 09/13] fix: keep the landing film from drawing NaN while the stage has no height MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 소개 화면은 스크롤 위치를 무대 높이로 나눠 애니메이션 시점을 정하는데, 모바일에서 무대가 자리 잡기 전이나 화면을 떠나는 순간에는 높이가 0 이라 0/0 = NaN 이 됐습니다. NaN 이 체크 모양의 좌표로 흘러 들어가 콘솔 에러가 났습니다. 높이가 없을 때는 첫 장면에 둡니다. --- src/pages/LandingPage/hooks/useFilmEngine.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 }; From da08eead99e52f7ad51b3350382dff40f7ae76f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B4=91=EC=9D=BC?= <54162245+pangil5634@users.noreply.github.com> Date: Tue, 27 Oct 2026 22:42:17 +0900 Subject: [PATCH 10/13] feat: list monthly achievement rows on the my page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 월별 달성률의 각 줄을 목록 항목으로 그려, "9월 70%" 가 스크린 리더에 한 줄 단위로 전달되고 테스트도 줄로 찾을 수 있습니다. --- src/pages/MyPage/components/MonthlyHistorySection.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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 = () => { )} -
+ ))}
)} From 7bf9f3bf26b20cb25bc9c63d7dd9509095073b76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B4=91=EC=9D=BC?= <54162245+pangil5634@users.noreply.github.com> Date: Tue, 27 Oct 2026 22:42:17 +0900 Subject: [PATCH 11/13] test: add my page and common p2 and p3 e2e cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 다크 테마 유지, 프로필과 6개월 달성률, 탈퇴 확인 취소, 로그인한 사용자의 소개·로그인 화면 이동, 공지 정렬과 이전·다음 이동, 푸터 링크 케이스를 추가했습니다. 공지는 모든 사용자가 함께 보는 데이터라 고유 제목과 1ms 간격의 시각으로 다른 실행과 섞이지 않게 합니다. --- e2e/cases/common.md | 7 ++-- e2e/cases/my.md | 6 ++-- e2e/specs/common/common.spec.ts | 55 +++++++++++++++++++++++++++++ e2e/specs/my/my-p2.spec.ts | 62 +++++++++++++++++++++++++++++++++ 4 files changed, 124 insertions(+), 6 deletions(-) create mode 100644 e2e/specs/common/common.spec.ts create mode 100644 e2e/specs/my/my-p2.spec.ts 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/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/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/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); + }); +}); From 0bbf1afe08a3b2bbe43a46fafc07101eca1d278e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B4=91=EC=9D=BC?= <54162245+pangil5634@users.noreply.github.com> Date: Tue, 27 Oct 2026 22:47:04 +0900 Subject: [PATCH 12/13] test: add a record mode to build e2e tests by clicking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit npm run e2e:record 는 평소 테스트와 같은 앱·에뮬레이터·로그인으로 예시 데이터를 넣은 홈을 열고 멈춥니다. Playwright Inspector 의 Record 로 화면을 조작하면 그 조작이 테스트 코드로 쌓입니다. TODO 에는 아직 재현하지 못한 앱 동작과 그동안의 재현 시도를 남겼습니다. --- e2e/README.md | 11 +++++++++++ e2e/TODO.md | 5 +++-- e2e/record/record.spec.ts | 22 ++++++++++++++++++++++ eslint.config.js | 2 +- package.json | 3 ++- playwright.record.config.ts | 16 ++++++++++++++++ tsconfig.e2e.json | 2 +- 7 files changed, 56 insertions(+), 5 deletions(-) create mode 100644 e2e/record/record.spec.ts create mode 100644 playwright.record.config.ts diff --git a/e2e/README.md b/e2e/README.md index 7a7f3b3..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` 를 돌린다. diff --git a/e2e/TODO.md b/e2e/TODO.md index 3a140db..4848eaa 100644 --- a/e2e/TODO.md +++ b/e2e/TODO.md @@ -1,7 +1,8 @@ -# 케이스 구현 전에 필요한 것 +# 남은 확인 거리 -케이스 후보를 뽑으며 찾은, 테스트를 막는 지점이다. 해결하면 지운다. +테스트를 만들며 찾았지만 아직 결론을 내지 못한 것이다. 해결하면 지운다. ## 확인이 필요한 앱 동작 - **다시 받아 온 집계가 진행 중인 체크 표시를 덮는다**: 체크하면 캘린더 숫자를 먼저 고쳐 두는데, 그사이 다른 곳(첫 문서 생성 뒤 재계산 등)이 월간 통계를 다시 받아 오면 고쳐 둔 값이 덮여 새로고침 전까지 옛 숫자가 보인다. 저장된 값은 맞다. 부하가 클 때 TC-HOME-004 에서 한 번 관찰했다. 체크가 끝났을 때 그사이 통계를 다시 받아 왔으면 한 번 더 받아 오는 식으로 막을 수 있으나, 재현을 고정하는 테스트를 먼저 만들어야 한다. + - 재현 시도: CPU 를 6배·10배로 늦추고 같은 조작(문서 생성 확인 → 할 일 추가 → 루틴 체크)을 56번 돌렸으나 끝까지 진행된 실행에서는 한 번도 나오지 않았다. 다시 관찰되면 그 실행의 trace(월간 통계 요청 순서)를 남겨 원인을 좁힌다. 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/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/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"] } From 36d4a6349f7f52105a39edf3203dc12e3de808c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B4=91=EC=9D=BC?= <54162245+pangil5634@users.noreply.github.com> Date: Tue, 27 Oct 2026 22:55:36 +0900 Subject: [PATCH 13/13] test: add moving unfinished tasks to another day from the list menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 녹화 모드로 직접 만든 흐름(리스트 메뉴 → 미완료 할 일을 다른 날 하기 → 완료 → 이동)에 결과 확인을 붙였습니다. 완료한 할 일은 남고 미완료만 다음 날로 옮겨지는지, 캘린더 숫자와 옮길 것이 없을 때의 안내를 확인합니다. --- e2e/cases/home.md | 8 ++++++++ e2e/specs/home/home-p2.spec.ts | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/e2e/cases/home.md b/e2e/cases/home.md index e5dc3ee..5dec77d 100644 --- a/e2e/cases/home.md +++ b/e2e/cases/home.md @@ -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/specs/home/home-p2.spec.ts b/e2e/specs/home/home-p2.spec.ts index 1df3017..44aa1d3 100644 --- a/e2e/specs/home/home-p2.spec.ts +++ b/e2e/specs/home/home-p2.spec.ts @@ -66,6 +66,39 @@ test.describe("홈: 할 일 옮기기", { tag: "@feature" }, () => { 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: "업무" });