diff --git a/.gitignore b/.gitignore index 50c8dda2af..614cbe4ffe 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,4 @@ dist-ssr *.sw? .env +cypress/screenshots \ No newline at end of file diff --git a/README.md b/README.md index 1dd510533d..c1da7ad067 100644 --- a/README.md +++ b/README.md @@ -50,3 +50,53 @@ FE 레벨1 영화 리뷰 미션 - [x] 스켈레톤 UI가 로딩중에 보이는지 테스트 - [x] 영화 검색시 필터링된 영화가 보이는지 테스트 - [x] 영화 검색시 검색어가 섹션 헤딩에 보이는지 테스트 + +# step2 상세 정보 & UI/UX 개선하기 + +## 기능 요구사항 분석 + +- [x] 영화 상세정보 조회 + - [x] 모달 컴포넌트를 만든다. + - [x] 영화 클릭시 모달이 렌더링된다. + - [x] 모달이 렌더링되었을때 외부영역이 스크롤되지 않도록 한다. + - [x] 영화 장르를 가져온다. + - [x] 모달을 닫는다. + - [x] 모달에는 영화의 상세정보를 담는다.(줄거리, 내 별점, 카테고리) + - [x] 사용자가 기록한 별점은 로컬 스토리지에 저장한다. (로컬 스토리지로부터 저장하고 가져온다..) + +- [x] 영화 더보기 페이징을 무한 스크롤로 전환 + - [x] intersection observer를 통해 구현 + - [x] 특정 지점의 스크롤 기준(thumbnail list 블럭 끝), loadMore 트리거 + - [x] 기존 더보기 관련 렌더링, 이벤트 핸들러 등록 제거 + +- [x] 반응형 웹 만들기 + - [x] Tablet + - [x] 헤더 + - [x] 모달 + - [x] 위치 + - [x] 상세정보 + + - [x] Mobile + - [x] 헤더 + - [x] 모달 + - [x] 위치 + - [x] 상세정보 + +## 개선사항 + +- [x] setUpMovieDetail에서 더보기 시 eventListener를 전부 등록하지 않고 추가된 데이터에만 적용할 수 없을까? +- [x] 영화의 자세한 정보가 담긴 모달을 정적인 형태로 두기. + - [x] dialog 태그로 전환 + - [x] 동적으로 변경되어야할 부분만 따로 변경 + +## 리팩토링 + +- [x] main로직을 분리 + - [x] index + - [x] search + - [x] movieDetail + +- [x] renderMovieDetail, renderMyRating을 Renderer로 이동 +- [x] renderMyRating에 switch/case 부분을 분리 + +- [x] 상수분리 diff --git a/cypress/e2e/spec.cy.ts b/cypress/e2e/spec.cy.ts index 133e0d3796..ba8c7357d7 100644 --- a/cypress/e2e/spec.cy.ts +++ b/cypress/e2e/spec.cy.ts @@ -1,10 +1,10 @@ -const createMockMovie = (id: number) => ({ +export const createMockMovie = (id: number) => ({ id, title: `어벤져스 ${id}`, poster_path: `/avengers${id}.jpg`, vote_average: 7.5, backdrop_path: `/backdrop${id}.jpg`, - genre_ids: [28], + genre_ids: [12, 99], original_language: "ko", original_title: `어벤져스 ${id}`, overview: "타노스를 조심해", @@ -15,12 +15,16 @@ const createMockMovie = (id: number) => ({ adult: false, }); -const createMoviesResponse = (count: number, page: number = 1) => ({ +export const createMoviesResponse = ( + count: number, + page: number = 1, + totalPages: number = 500, +) => ({ page, results: Array.from({ length: count }, (_, i) => createMockMovie((page - 1) * 20 + i + 1), ), - total_pages: 500, + total_pages: totalPages, total_results: 10000, }); @@ -29,6 +33,9 @@ describe("영화 리뷰 앱", () => { cy.intercept("GET", "**/movie/popular*", createMoviesResponse(20)).as( "getPopularMovies", ); + cy.intercept("GET", "**/genre/movie/list*", { + fixture: "genres.json", + }).as("getGenres"); cy.visit("/"); }); @@ -63,45 +70,48 @@ describe("영화 리뷰 앱", () => { }); describe("더 보기", () => { - it("더 보기 클릭 시 영화가 20개 추가 렌더링된다", () => { + it("end-of-thumbnail-list에 도달했을떄 영화가 20개 추가 렌더링된다", () => { cy.wait("@getPopularMovies"); cy.intercept("GET", "**/movie/popular*", createMoviesResponse(20, 2)).as( "getMoreMovies", ); - cy.get(".load-more-button").click(); + cy.get("#end-of-thumbnail-list").scrollIntoView(); cy.wait("@getMoreMovies"); cy.get(".thumbnail-list li").should("have.length", 40); }); - it("마지막 페이지일 때 더 보기 버튼이 숨겨진다", () => { + it("마지막 페이지일 때 더 이상 영화를 가져오지 않는다", () => { cy.wait("@getPopularMovies"); + cy.intercept( + "GET", + "**/movie/popular*", + createMoviesResponse(5, 1, 1), + ).as("getMoreMovies"); + cy.get("#end-of-thumbnail-list").scrollIntoView(); + cy.wait("@getMoreMovies"); - cy.intercept("GET", "**/movie/popular*", createMoviesResponse(5, 2)).as( - "getLastPageMovies", - ); - - cy.get(".load-more-button").click(); - cy.wait("@getLastPageMovies"); + cy.get(".thumbnail-list li").should("have.length", 25); - cy.get(".load-more-button").should("not.be.visible"); + cy.get("#end-of-thumbnail-list").scrollIntoView(); + cy.wait(500); + cy.get(".thumbnail-list li").should("have.length", 25); }); it("더 보기 API 실패 시 에러 메시지가 렌더링된다", () => { cy.wait("@getPopularMovies"); cy.intercept("GET", "**/movie/popular*", { statusCode: 500 }).as( - "getMoreMoviesError", + "getMoreMovies", ); - cy.get(".load-more-button").click(); - cy.wait("@getMoreMoviesError"); - + cy.get("#end-of-thumbnail-list").scrollIntoView(); + cy.wait("@getMoreMovies"); cy.get(".notice-text").should( "contain.text", - "영화 정보를 불러오는 데 실패했습니다.", + "오류가 발생했습니다. 다시 시도해주세요.", ); }); }); @@ -122,39 +132,28 @@ describe("영화 리뷰 앱", () => { cy.get("section > h2").should("contain.text", "액션"); }); - it("검색 결과가 마지막 페이지일 때 더 보기 버튼이 숨겨진다", () => { - cy.wait("@getPopularMovies"); - - cy.intercept("GET", "**/search/movie*", createMoviesResponse(5)).as( - "searchLastPage", - ); - - cy.get(".search-form input").type("액션"); - cy.get(".search-form").submit(); - cy.wait("@searchLastPage"); - - cy.get(".load-more-button").should("not.be.visible"); - }); - - it("검색 더 보기에서 마지막 페이지일때 더 보기 버튼이 숨겨진다", () => { + it("검색 결과가 마지막 페이지일 때 더 이상 영화를 가져오지 않는다.", () => { cy.wait("@getPopularMovies"); - cy.intercept("GET", "**/search/movie*", createMoviesResponse(20)).as( - "searchMovies", + cy.intercept("GET", "**/search/movie*", createMoviesResponse(3, 1, 1)).as( + "getMoreMovies", ); + cy.intercept( + { method: "GET", url: "**/search/movie*", times: 1 }, + createMoviesResponse(20), + ).as("searchMovies"); cy.get(".search-form input").type("액션"); cy.get(".search-form").submit(); cy.wait("@searchMovies"); - cy.intercept("GET", "**/search/movie*", createMoviesResponse(3, 2)).as( - "searchLastPage", - ); - - cy.get(".load-more-button").click(); - cy.wait("@searchLastPage"); + cy.get("#end-of-thumbnail-list").scrollIntoView(); + cy.wait("@getMoreMovies"); + cy.get(".thumbnail-list li").should("have.length", 23); - cy.get(".load-more-button").should("not.be.visible"); + cy.get("#end-of-thumbnail-list").scrollIntoView(); + cy.wait(500); + cy.get(".thumbnail-list li").should("have.length", 23); }); it("검색 결과가 없을 때 안내 메시지가 렌더링된다", () => { @@ -186,15 +185,14 @@ describe("영화 리뷰 앱", () => { cy.wait("@searchMovies"); cy.intercept("GET", "**/search/movie*", { statusCode: 500 }).as( - "searchMoreMoviesError", + "getMoreMovies", ); - - cy.get(".load-more-button").click(); - cy.wait("@searchMoreMoviesError"); + cy.get("#end-of-thumbnail-list").scrollIntoView(); + cy.wait("@getMoreMovies"); cy.get(".notice-text").should( "contain.text", - "영화 정보를 불러오는 데 실패했습니다.", + "오류가 발생했습니다. 다시 시도해주세요.", ); }); }); @@ -210,7 +208,7 @@ describe("영화 리뷰 앱", () => { cy.get(".notice-text").should( "contain.text", - "영화 정보를 불러오는 데 실패했습니다.", + "오류가 발생했습니다. 다시 시도해주세요.", ); }); @@ -234,5 +232,112 @@ describe("영화 리뷰 앱", () => { cy.get(".thumbnail-list li").should("have.length", 5); cy.get("section > h2").should("contain.text", "액션"); }); + + [ + ["인증에 실패했습니다. API 키를 확인해주세요.", 401], + ["요청한 정보를 찾을 수 없습니다", 404], + ["오류가 발생했습니다. 다시 시도해주세요.", 500], + ].forEach(([message, statusCode]) => { + it(`API ${statusCode} 에러 시 에러 메시지가 렌더링된다`, () => { + cy.wait("@getPopularMovies"); + + cy.intercept("GET", "**/movie/popular*", { statusCode }).as( + "getMoreMovies", + ); + + cy.get("#end-of-thumbnail-list").scrollIntoView(); + cy.wait("@getMoreMovies"); + + cy.get(".notice-text").should("contain.text", message); + }); + }); + }); + + describe("영화 정보", () => { + const origin = new URL(Cypress.config("baseUrl") as string).origin; + beforeEach(() => { + cy.wait("@getPopularMovies"); + cy.wait("@getGenres"); + }); + it("영화를 클릭하면 영화에 대한 자세한 정보가 담긴 모달이 렌더링된다", () => { + cy.get(".thumbnail-list li").first().click(); + cy.get(".modal").should("be.visible"); + }); + + it("영화 카테고리 아이디는 이름으로 변환되어 렌더링된다.", () => { + cy.get(".thumbnail-list li").first().click(); + cy.get("#movie-detail-category").should("have.text", "모험, 다큐멘터리"); + cy.get("#movie-detail-release-year").should("have.text", "2026"); + }); + + it("모달 닫기 버튼을 클릭하면 영화 정보 모달이 제거된다.", () => { + cy.get(".thumbnail-list li").first().click(); + cy.get("#closeModal").click(); + cy.get("dialog").should("not.be.visible"); + }); + + it("영화를 클릭하면 영화 ID가 영화에 대한 자세한 정보가 담긴 컨테이너의 data-movie-id 속성에 저장된다.", () => { + cy.get(".thumbnail-list li").eq(2).click(); + cy.get("#movie-detail-container") + .invoke("attr", "data-movie-id") + .should("eq", "3"); + }); + + it("별점을 클릭하면 로컬스토리지에 선택된 영화에 내 평점이 저장된다.", () => { + function assertLocalStorageValue(key: string, value: string) { + cy.getAllLocalStorage().then((result) => { + expect(result).to.deep.equal({ + [origin]: { + [key]: value, + }, + }); + }); + } + cy.get(".thumbnail-list li").eq(4).click(); + cy.get(".my-rating-container button").eq(0).click(); + assertLocalStorageValue("movie-5-my-rating", "2"); + cy.get(".my-rating-container button").eq(1).click(); + assertLocalStorageValue("movie-5-my-rating", "4"); + cy.get(".my-rating-container button").eq(2).click(); + assertLocalStorageValue("movie-5-my-rating", "6"); + cy.get(".my-rating-container button").eq(3).click(); + assertLocalStorageValue("movie-5-my-rating", "8"); + cy.get(".my-rating-container button").eq(4).click(); + assertLocalStorageValue("movie-5-my-rating", "10"); + }); + + it("별점을 클릭하면 별점에 따라 내 평점 렌더링이 변화된다.", () => { + const ratingCases = [ + { buttonIndex: 0, rating: 2, label: "최악이에요", filledCount: 1 }, + { buttonIndex: 1, rating: 4, label: "별로에요", filledCount: 2 }, + { buttonIndex: 2, rating: 6, label: "보통이에요", filledCount: 3 }, + { buttonIndex: 3, rating: 8, label: "재미있어요", filledCount: 4 }, + { buttonIndex: 4, rating: 10, label: "명작이에요", filledCount: 5 }, + ]; + + cy.get(".thumbnail-list li").eq(4).click(); + + ratingCases.forEach(({ buttonIndex, rating, label, filledCount }) => { + cy.get(".my-rating-container button").eq(buttonIndex).click(); + cy.get("#my-rating-to-string").should("have.text", label); + cy.get("#my-rating-ratio").should("have.text", `(${rating}/10)`); + for (let i = 0; i < ratingCases.length; i++) { + const expectedSrc = + i < filledCount ? "star_filled.png" : "star_empty.png"; + cy.get("#my-rating button") + .eq(i) + .find("img") + .should("have.attr", "src") + .and("include", expectedSrc); + } + }); + }); + + it("더 가져오기 이후에 존재했던 영화를 클릭시 자세한 정보가 담긴 모달이 렌더링된다.", () => { + cy.get("#end-of-thumbnail-list").scrollIntoView(); + cy.wait(500); + cy.get(".thumbnail-list li").first().click(); + cy.get(".modal").should("be.visible"); + }); }); }); diff --git a/cypress/fixtures/genres.json b/cypress/fixtures/genres.json new file mode 100644 index 0000000000..bce2de1265 --- /dev/null +++ b/cypress/fixtures/genres.json @@ -0,0 +1,80 @@ +{ + "genres": [ + { + "id": 28, + "name": "액션" + }, + { + "id": 12, + "name": "모험" + }, + { + "id": 16, + "name": "애니메이션" + }, + { + "id": 35, + "name": "코미디" + }, + { + "id": 80, + "name": "범죄" + }, + { + "id": 99, + "name": "다큐멘터리" + }, + { + "id": 18, + "name": "드라마" + }, + { + "id": 10751, + "name": "가족" + }, + { + "id": 14, + "name": "판타지" + }, + { + "id": 36, + "name": "역사" + }, + { + "id": 27, + "name": "공포" + }, + { + "id": 10402, + "name": "음악" + }, + { + "id": 9648, + "name": "미스터리" + }, + { + "id": 10749, + "name": "로맨스" + }, + { + "id": 878, + "name": "SF" + }, + { + "id": 10770, + "name": "TV 영화" + }, + { + "id": 53, + "name": "스릴러" + }, + { + "id": 10752, + "name": "전쟁" + }, + { + "id": 37, + "name": "서부" + } + ] +} diff --git a/index.html b/index.html index 3c02d0a403..e1cb22b0d3 100644 --- a/index.html +++ b/index.html @@ -7,6 +7,8 @@ + +
