-
Notifications
You must be signed in to change notification settings - Fork 155
[2단계 - 영화 목록 불러오기] 코브 미션 제출합니다. #307
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: th-97
Are you sure you want to change the base?
Changes from all commits
5c5fdcb
ad981b6
f4adca4
4a34844
5c4ea88
ee8a5bd
f76602a
56a6a32
11f5018
657ab71
e915275
39c1ff7
43482eb
561807d
25c2a57
220e197
d064e90
6996cb7
5b2fa1e
b5a19f5
abdaf6f
1827722
8e3822c
7138f1b
010fbc8
8bd99fa
7cbe2af
48b564c
56ba9c9
27b27b8
d019e32
9a28743
20805b4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| # CLAUDE.md | ||
|
|
||
| This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. | ||
|
|
||
| ## Commands | ||
|
|
||
| ```bash | ||
| npm run dev # 개발 서버 실행 | ||
| npm run build # TypeScript 타입 체크 후 Vite 빌드 | ||
| npm run test-unit # Vitest 단위 테스트 실행 | ||
| npm run test-e2e # Cypress E2E 테스트 실행 (UI) | ||
| npm run preview # 빌드 결과물 미리보기 | ||
| ``` | ||
|
|
||
| 단위 테스트 단일 실행: | ||
| ```bash | ||
| npx vitest run --reporter=verbose <파일명> | ||
| ``` | ||
|
|
||
| 환경변수: `VITE_TMDB_API_TOKEN`이 `.env`에 필요 (TMDB API Bearer token). | ||
|
|
||
| ## 아키텍처 | ||
|
|
||
| 3개 레이어로 구성되며 단방향 의존성을 유지한다. 아래 레이어는 위 레이어를 모른다. | ||
|
|
||
| ``` | ||
| Presentation → Domain → Data Source | ||
| ``` | ||
|
|
||
| ### Data Source | ||
| - `src/movieAPIResponse.ts` — TMDB API fetch. 응답을 `MoviePage { results, totalPages }`로 정규화. 에러는 throw만 함. API 필드명(snake_case)은 이 파일 안에서만 존재해야 한다. | ||
|
|
||
| ### Domain | ||
| - `src/domain/MovieBrowser.ts` — 순수 상태 기계. DOM/fetch 의존성 없음. 페이지네이션 상태, 검색 모드, 배너/버튼 표시 여부를 관리. 렌더러가 필요한 값(`showsBanner`, `sectionTitle`, `isNewSession`, `canLoadMore`)을 직접 계산해서 노출한다. | ||
|
|
||
| ### Presentation | ||
| - `src/presentation/fetchStrategies.ts` — `FetchStrategy` 타입과 `popularStrategy` / `searchStrategy`. 모드별 fetch 전략을 교체 가능하게 분리. | ||
| - `src/presentation/MovieController.ts` — 이벤트 → 도메인 명령 → 렌더 흐름 조율. `#createNewRequest()`, `#applyResult()`, `#handleError()`로 분해됨. 에러 처리와 로딩 상태 관리 담당. | ||
| - `src/presentation/MovieRenderer.ts` — `MovieBrowser` 상태를 받아 DOM 반영. 도메인 상태를 해석하지 않고 그대로 사용. `startLoading()` / `stopLoading()`은 컨트롤러가 호출하고, `render()`는 데이터 표시만 담당. | ||
| - `src/eventListeners.ts` — DOM 이벤트 감지 및 keyword 추출. 유저 입력 검증(빈 keyword 필터)도 여기서 처리. | ||
| - `src/initTemplate.ts` — 앱 초기화 HTML 주입. App에서 최초 1회 호출. | ||
| - `src/App.ts` — 부팅만 담당. `initTemplate → initSearchSubmit → initLoadMore → loadPopular` 순서로 초기화. | ||
|
|
||
| ## 핵심 설계 규칙 | ||
|
|
||
| - **레이어 위반 금지**: 도메인이 DOM을 참조하거나, 렌더러가 fetch를 참조하면 안 된다. | ||
| - **도메인은 UI 결정을 내린다**: "배너를 보여야 하는가"(`showsBanner`), "섹션 제목이 무엇인가"(`sectionTitle`) 등은 렌더러가 판단하지 않고 도메인 getter에서 반환한다. | ||
| - **렌더러는 상태를 해석하지 않는다**: `render(state, movies)`는 `state.canLoadMore` 등을 그대로 사용하며, 모드를 직접 비교하는 분기(`if mode === 'search'`)를 가지면 안 된다. | ||
| - **FetchStrategy 교체로 모드 전환**: `search()`는 `#strategy`를 `searchStrategy`로 교체하고, `loadPopular()`는 `popularStrategy`를 유지한다. 컨트롤러 내부에 fetch 분기(`if mode`)가 없어야 한다. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -26,3 +26,32 @@ FE 레벨1 영화 리뷰 미션 | |
| - [x] 배너 DOM 제거 및 추가 | ||
|
|
||
| - [x] 결과 없음 처리: 검색 결과가 존재하지 않을 경우 "검색 결과가 없습니다"라는 안내 메시지를 출력합니다. | ||
|
|
||
| ## step2 | ||
|
|
||
| 1. 영화 상세 정보 조회 | ||
|
|
||
| - [x] 영화 포스터 클릭 시 모달 창을 통해 상세 정보를 표시합니다. | ||
| - [x] API에서 제공하는 항목(제목, 포스터, 평점, 개봉연도, 장르, 줄거리)을 활용하여 상세 정보를 보여줍니다. | ||
| - [x] ESC 키를 누르거나 모달 외부 영역 또는 닫기 버튼 클릭 시 모달을 닫을 수 있습니다. | ||
|
|
||
| 2. 별점 매기기 | ||
|
|
||
| - [x] 사용자는 영화에 대해 별점을 줄 수 있습니다. | ||
| - [x] 새로고침 후에도 사용자가 남긴 별점은 유지됩니다. (localStorage 사용) | ||
| - [x] 별점은 5개로 구성되며 한 개당 2점입니다. (1점 단위 미지원) | ||
| - 2점: 최악이예요 / 4점: 별로예요 / 6점: 보통이에요 / 8점: 재미있어요 / 10점: 명작이에요 | ||
|
|
||
| 3. 무한 스크롤 만들기 | ||
|
|
||
| - [x] 더보기 버튼 삭제 | ||
| - [x] 사용자가 스크롤 끝에 도달하면 다시 로딩 | ||
|
|
||
| 3. UI/UX 개선 | ||
|
|
||
| - [x] Figma 시안을 기준으로 UI를 구현합니다. | ||
|
|
||
| 4 E2E 구현 | ||
|
|
||
|
Comment on lines
+50
to
+55
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 체크리스트 번호 표기를 정리해 주세요. Line 50이 다시 🤖 Prompt for AI Agents |
||
| - [x] 무한스크롤 테스트 | ||
| - [x] 자세히 보기 테스트 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. e2e를 꼼꼼히 작성해주셨는데요! 무한스크롤기능이 추가된만큼 해당 기능에 대한 테스트코드가 추가되면 좋을 것 같아요! |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,87 +1,163 @@ | ||
| describe("인기영화 렌더링 테스트", () => { | ||
| beforeEach(() => { | ||
| cy.intercept("GET", "**/movie/popular*").as("getMovies"); | ||
| cy.intercept("GET", "**/movie/popular*", | ||
| { fixture: "movies.json" }, | ||
| ).as("getMovies"); | ||
| cy.intercept("GET", "**/movie/popular*page=2*", | ||
| { fixture: "movies2.json"} | ||
| ).as("getMoviesPage2"); | ||
| cy.intercept("GET", "**/movie/1*", { fixture: "movieDetail.json" }).as("getDetail"); | ||
| cy.visit("localhost:5173"); | ||
| }); | ||
|
|
||
| it("웹에 접근을 하면 인기 영화 20개를 랜더링 한다", () => { | ||
| it("웹에 접근을 하면 인기 영화 20개가 보인다", () => { | ||
| cy.wait("@getMovies"); | ||
| cy.get(".thumbnail-list li").should("have.length", 20); | ||
| }); | ||
|
|
||
| it("더보기 버튼을 누르면 20개를 추가로 렌더링 한다", () => { | ||
| cy.wait("@getMovies"); | ||
| cy.get("#load-movie-button").click(); | ||
| it("스크롤을 끝까지 내렸을때 추가로 랜더링 한다", () => { | ||
| cy.wait("@getMovies"); | ||
| cy.scrollTo('bottom'); | ||
| cy.wait('@getMoviesPage2'); | ||
| cy.get(".thumbnail-list li").should("have.length", 40); | ||
| }); | ||
| }); | ||
|
|
||
| describe("인기 영화 더보기 버튼이 숨겨지는지 테스트", () => { | ||
| beforeEach(() => { | ||
| cy.intercept( | ||
| "GET", | ||
| "https://api.themoviedb.org/3/movie/popular?language=en-US&page=1", | ||
| { fixture: "movies.json" }, | ||
| ).as("getMovies"); | ||
| it("영화 클릭시 모달창 열림", () =>{ | ||
| cy.wait("@getMovies"); | ||
| cy.get('.thumbnail-list li').first().click(); | ||
| cy.get('.modal-background').should('have.class', 'active'); | ||
|
Comment on lines
+27
to
+28
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. e2e테스트를 아주 꼼꼼히 작성해주셨군요! 좋습니다 테스트 곳곳에 공통된 로직이 있어서 추상화를 시도해주셔도 좋을 것 같아요! |
||
| }); | ||
|
|
||
| cy.intercept( | ||
| "GET", | ||
| "https://api.themoviedb.org/3/movie/popular?language=en-US&page=2", | ||
| { fixture: "movies2.json" }, | ||
| ).as("getMoviesPage2"); | ||
| it("ESC를 누를시 모달창 닫힘",() =>{ | ||
| cy.wait("@getMovies"); | ||
| cy.get('.thumbnail-list li').first().click(); | ||
| cy.get('.modal-background').should('have.class', 'active'); | ||
| cy.get('body').type('{esc}'); | ||
| cy.get('.modal-background').should('not.have.class', 'active'); | ||
| }); | ||
|
|
||
| cy.visit("http://localhost:5173"); | ||
| it("닫기 버튼 클릭시 모달창 닫힘", () => { | ||
| cy.wait("@getMovies"); | ||
| cy.get('.thumbnail-list li').first().click(); | ||
| cy.get('.modal-background').should('have.class', 'active'); | ||
| cy.get('#closeModal').click(); | ||
| cy.get('.modal-background').should('not.have.class', 'active'); | ||
| }); | ||
|
|
||
| it("마지막 페이지 도달 시 더보기 버튼이 사라진다", () => { | ||
| it("별점 클릭시 랜더링 하기", () =>{ | ||
| cy.wait("@getMovies"); | ||
| cy.get("#load-movie-button").click(); | ||
| cy.wait("@getMoviesPage2"); | ||
| cy.get("#load-movie-button").should("have.css", "display", "none"); | ||
| cy.get('.thumbnail-list li').first().click(); | ||
| cy.get('#rate-stars img').last().click(); | ||
| cy.get('#rate-evaluate').should('have.text', '명작이에요'); | ||
| cy.get('#rate-score').should('have.text', '(10/10)'); | ||
| }); | ||
|
|
||
| it("별점이 모달을 닫고 다시 열어도 유지된다", () => { | ||
| cy.wait("@getMovies"); | ||
| cy.get('.thumbnail-list li').first().click(); | ||
| cy.get('#rate-stars img').last().click(); | ||
| cy.get('#closeModal').click(); | ||
| cy.get('.thumbnail-list li').first().click(); | ||
| cy.get('#rate-evaluate').should('have.text', '명작이에요'); | ||
| cy.get('#rate-score').should('have.text', '(10/10)'); | ||
| }); | ||
| }); | ||
|
|
||
| describe("검색영화 렌더링 테스트", () => { | ||
| beforeEach(() => { | ||
| cy.intercept("GET", "**/search/movie*", { fixture: "movies.json" }).as("searchMovies"); | ||
| cy.intercept("GET", "**/movie/popular*", { fixture: "movies.json" }).as("getMovies"); | ||
| cy.intercept("GET", "**/movie/1*", { fixture: "movieDetail.json" }).as("getDetail"); | ||
| cy.visit("localhost:5173"); | ||
| }); | ||
|
|
||
| it("Harry Potter를 검색 하면 검색에 따른 영화를 랜더링 한다.", () => { | ||
| cy.wait("@getMovies"); | ||
| cy.get(".search-input").type("Harry Potter"); | ||
| cy.get(".search-button").click(); | ||
| cy.get(".thumbnail-list li").should("have.length.at.least", 1); | ||
| cy.wait("@searchMovies"); | ||
| cy.get(".thumbnail-list li").should("have.length", 20); | ||
| }); | ||
|
|
||
| it("뷁뷁뷁을 검색 하면 검색 결과가 없어야 한다.", () => { | ||
| cy.intercept("GET", "**/search/movie*", { body: { results: [], total_pages: 1 } }).as("searchEmpty"); | ||
| cy.wait("@getMovies"); | ||
| cy.get(".search-input").type("뷁뷁뷁"); | ||
| cy.get(".search-input").type("{enter}"); | ||
| cy.wait("@searchEmpty"); | ||
| cy.get(".thumbnail-list li").should("have.length", 0); | ||
| cy.get("#no-result").contains("검색 결과가 없습니다.").should("exist"); | ||
| }); | ||
| }); | ||
|
|
||
| describe("검색 영화 더보기 버튼이 숨겨지는지 테스트", () => { | ||
| beforeEach(() => { | ||
| cy.intercept("GET", "**/search/movie*page=1*", { | ||
| fixture: "movies.json", | ||
| }).as("getMovies"); | ||
| it("영화 클릭시 모달창 열림", () => { | ||
| cy.wait("@getMovies"); | ||
| cy.get(".search-input").type("Harry Potter"); | ||
| cy.get(".search-button").click(); | ||
| cy.wait("@searchMovies"); | ||
| cy.get('.thumbnail-list li').first().click(); | ||
| cy.get('.modal-background').should('have.class', 'active'); | ||
| }); | ||
|
|
||
| cy.intercept("GET", "**/search/movie*page=2*", { | ||
| fixture: "movies2.json", | ||
| }).as("getMoviesPage2"); | ||
| it("닫기 버튼 클릭시 모달창 닫힘", () => { | ||
| cy.wait("@getMovies"); | ||
| cy.get(".search-input").type("Harry Potter"); | ||
| cy.get(".search-button").click(); | ||
| cy.wait("@searchMovies"); | ||
| cy.get('.thumbnail-list li').first().click(); | ||
| cy.get('#closeModal').click(); | ||
| cy.get('.modal-background').should('not.have.class', 'active'); | ||
| }); | ||
|
|
||
| cy.visit("http://localhost:5173"); | ||
| it("ESC를 누를시 모달창 닫힘", () => { | ||
| cy.wait("@getMovies"); | ||
| cy.get(".search-input").type("Harry Potter"); | ||
| cy.get(".search-button").click(); | ||
| cy.wait("@searchMovies"); | ||
| cy.get('.thumbnail-list li').first().click(); | ||
| cy.get('body').type('{esc}'); | ||
| cy.get('.modal-background').should('not.have.class', 'active'); | ||
| }); | ||
|
|
||
| it("마지막 페이지 도달 시 더보기 버튼이 사라진다", () => { | ||
| cy.get(".search-input").type("영화"); | ||
| cy.get(".search-input").type("{enter}"); | ||
| it("별점 클릭시 랜더링 하기", () => { | ||
| cy.wait("@getMovies"); | ||
| cy.get(".search-input").type("Harry Potter"); | ||
| cy.get(".search-button").click(); | ||
| cy.wait("@searchMovies"); | ||
| cy.get('.thumbnail-list li').first().click(); | ||
| cy.get('#rate-stars img').last().click(); | ||
| cy.get('#rate-evaluate').should('have.text', '명작이에요'); | ||
| cy.get('#rate-score').should('have.text', '(10/10)'); | ||
| }); | ||
|
|
||
| it("별점이 모달을 닫고 다시 열어도 유지된다", () => { | ||
| cy.wait("@getMovies"); | ||
| cy.get("#load-movie-button").click(); | ||
| cy.wait("@getMoviesPage2"); | ||
| cy.get("#load-movie-button").should("have.css", "display", "none"); | ||
| cy.get(".search-input").type("Harry Potter"); | ||
| cy.get(".search-button").click(); | ||
| cy.wait("@searchMovies"); | ||
| cy.get('.thumbnail-list li').first().click(); | ||
| cy.get('#rate-stars img').last().click(); | ||
| cy.get('#closeModal').click(); | ||
| cy.get('.thumbnail-list li').first().click(); | ||
| cy.get('#rate-evaluate').should('have.text', '명작이에요'); | ||
| cy.get('#rate-score').should('have.text', '(10/10)'); | ||
| }); | ||
|
|
||
| it("연결이 되어 있지 않았을때 에러 표시를 한다", () => { | ||
| cy.intercept("GET", "**/search/movie*", { forceNetworkError: true | ||
| }).as("searchError"); | ||
|
|
||
| const alertStub = cy.stub(); | ||
| cy.on('window:alert', alertStub); | ||
|
|
||
| cy.wait("@getMovies"); | ||
| cy.get(".search-input").type("Harry Potter"); | ||
| cy.get(".search-button").click(); | ||
|
|
||
| cy.wait("@searchError").then(() => { | ||
| expect(alertStub).to.have.been.called; | ||
| }); | ||
| }); | ||
|
|
||
| }); | ||
|
|
||
| describe("Skeleton UI 테스트", () => { | ||
|
|
@@ -136,3 +212,67 @@ describe("Skeleton UI 테스트", () => { | |
| }); | ||
| }); | ||
| }); | ||
|
|
||
| // describe("인기 영화 더보기 버튼이 숨겨지는지 테스트", () => { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 주석은 지워도 될 것 같은데 어떻게 생각하세요? |
||
| // beforeEach(() => { | ||
| // cy.intercept( | ||
| // "GET", | ||
| // "https://api.themoviedb.org/3/movie/popular?language=en-US&page=1", | ||
| // { fixture: "movies.json" }, | ||
| // ).as("getMovies"); | ||
|
|
||
| // cy.intercept( | ||
| // "GET", | ||
| // "https://api.themoviedb.org/3/movie/popular?language=en-US&page=2", | ||
| // { fixture: "movies2.json" }, | ||
| // ).as("getMoviesPage2"); | ||
|
|
||
| // cy.visit("http://localhost:5173"); | ||
| // }); | ||
|
|
||
| // TODO: 더보기 버튼 → 무한 스크롤로 변경 | ||
| // 마지막 페이지 도달 시 스크롤해도 추가 요청이 발생하지 않는다 | ||
| // it("마지막 페이지 도달 시 더보기 버튼이 사라진다", () => { | ||
| // cy.wait("@getMovies"); | ||
| // cy.get("#load-movie-button").click(); | ||
| // cy.wait("@getMoviesPage2"); | ||
| // cy.get("#load-movie-button").should("have.css", "display", "none"); | ||
| // }); | ||
| // }); | ||
|
|
||
| // describe("검색 영화 더보기 버튼이 숨겨지는지 테스트", () => { | ||
| // beforeEach(() => { | ||
| // cy.intercept("GET", "**/search/movie*page=1*", { | ||
| // fixture: "movies.json", | ||
| // }).as("getMovies"); | ||
|
|
||
| // cy.intercept("GET", "**/search/movie*page=2*", { | ||
| // fixture: "movies2.json", | ||
| // }).as("getMoviesPage2"); | ||
|
|
||
| // cy.visit("http://localhost:5173"); | ||
| // }); | ||
|
|
||
| // // TODO: 더보기 버튼 → 무한 스크롤로 변경 | ||
| // // 검색 결과 마지막 페이지 도달 시 스크롤해도 추가 요청이 발생하지 않는다 | ||
| // it("마지막 페이지 도달 시 더보기 버튼이 사라진다", () => { | ||
| // cy.get(".search-input").type("영화"); | ||
| // cy.get(".search-input").type("{enter}"); | ||
| // cy.wait("@getMovies"); | ||
| // cy.get("#load-movie-button").click(); | ||
| // cy.wait("@getMoviesPage2"); | ||
| // cy.get("#load-movie-button").should("have.css", "display", "none"); | ||
| // }); | ||
| // }); | ||
|
|
||
| // TODO: 새로 추가할 테스트 | ||
| // describe("무한 스크롤 테스트", () => { | ||
| // it("스크롤을 끝까지 내리면 다음 페이지 영화가 추가 렌더링된다") | ||
| // it("로딩 중 스크롤해도 중복 요청이 발생하지 않는다") | ||
| // }); | ||
| // | ||
| // describe("모달 테스트", () => { | ||
| // it("영화 클릭 시 모달이 열린다") | ||
| // it("닫기 버튼 클릭 시 모달이 닫힌다") | ||
| // it("별점 클릭 시 별점이 저장된다") | ||
| // }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| { | ||
| "id": 1, | ||
| "title": "인사이드 아웃 2", | ||
| "poster_path": "/test0.jpg", | ||
| "vote_average": 7.8, | ||
| "overview": "라이리의 성장과 새로운 감정들의 이야기.", | ||
| "release_date": "2024-06-14", | ||
| "genres": [ | ||
| { "id": 16, "name": "애니메이션" }, | ||
| { "id": 35, "name": "코미디" }, | ||
| { "id": 18, "name": "드라마" } | ||
| ] | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
MovieController 설명은 현재 구현 기준으로 다시 맞춰두는 편이 좋겠습니다.
Line 38은
#createNewRequest(),#applyResult(),#handleError()같은 private method 구조를 전제로 설명하지만, 제공된src/presentation/MovieController.ts:17-42스니펫은 함수 기반createNewRequest/handleSuccess/handleError흐름입니다. 이 문서가 작업 지침으로 쓰이는 파일이라면 현재 코드와 맞춰 두는 편이 안전합니다.🤖 Prompt for AI Agents