Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
a655cf0
refactor: step1 병합 이후 추가 변경사항 반영
Antoliny0919 Apr 9, 2026
77b3d21
docs: 기능 요구사항 분석 추가
Antoliny0919 Apr 9, 2026
b493754
feat: 영화 클릭시 영화에 대한 자세한 정보가 담긴 모달이 렌더링 되는 기능 추가
Antoliny0919 Apr 10, 2026
8a0f978
docs: 필요하지 않은 기능 요구사항 제거
Antoliny0919 Apr 10, 2026
2b9f50b
docs: 기능 구현 사항 추가
Antoliny0919 Apr 10, 2026
9815964
fix: 영화 클릭시 클릭한 영화에 알맞는 데이터가 렌더링되도록 수정
Antoliny0919 Apr 10, 2026
9dbce00
docs: 영화 장르를 가져오는 기능 요구사항 추가
Antoliny0919 Apr 10, 2026
f613676
feat: 영화 카테고리를 가져오는 API 추가
Antoliny0919 Apr 11, 2026
1142b67
feat: 영화의 자세한정보가 담긴 모달을 닫는 기능 추가
Antoliny0919 Apr 11, 2026
c1fe458
fix: 모달이 존재할때 외부가 스크롤 되지 않도록 수정
Antoliny0919 Apr 11, 2026
89a3c16
feat: 영화 자세한정보에서 각 섹션별 라벨 추가
Antoliny0919 Apr 11, 2026
4713a92
feat: 정적인 모달 추가
Antoliny0919 Apr 11, 2026
c8da22d
refactor: 영화 클릭시 다이얼로그가 open되도록 전환
Antoliny0919 Apr 11, 2026
ebb21cb
refactor: 다이얼로그를 닫는 이벤트를 1회만 추가하도록 전환
Antoliny0919 Apr 11, 2026
3e933a6
refactor: 영화 자세한 정보를 렌더링 하는 방식 전환
Antoliny0919 Apr 11, 2026
e7e846b
feat: 내 평점을 기록하는 레이아웃 추가
Antoliny0919 Apr 12, 2026
29badd0
feat: 평점을 클릭했을때 로컬스토리지에 개인 평점을 저장하는 기능 추가
Antoliny0919 Apr 12, 2026
1e869be
feat: 별점 클릭시 평점 UI를 업데이트하는 기능 추가
Antoliny0919 Apr 12, 2026
ecc5a53
docs: 개선 사항 체크
Antoliny0919 Apr 12, 2026
b83e537
docs: 리팩토링 사항 추가
Antoliny0919 Apr 12, 2026
3cd358a
refactor: 영화 더 가져오는 기능을 무한 스크롤로 전환
Antoliny0919 Apr 12, 2026
789897e
fix: 폴더 이름 오타 수정
Antoliny0919 Apr 12, 2026
fd0636e
refactor: Main로직을 페이지별로 분리
Antoliny0919 Apr 12, 2026
437b3ee
test: 검색 더 보기 테스트 간헐적 실패 수정
Antoliny0919 Apr 12, 2026
c769ba0
chore: cypress screenshot ignore 추가
Antoliny0919 Apr 12, 2026
9a9511d
refactor: 별점을 문자열로 변환하는 로직 분리
Antoliny0919 Apr 12, 2026
621615c
refactor: Renderer로직을 페이지별로 분리
Antoliny0919 Apr 12, 2026
8dbfc66
docs: 리팩토링 사항 체크
Antoliny0919 Apr 12, 2026
39ce2c6
refactor: 레이팅관련 상수 분리
Antoliny0919 Apr 12, 2026
9401033
test: 필요하지 않은 테스트 제거
Antoliny0919 Apr 12, 2026
ac08292
fix: 스켈레톤 제거 시 이벤트 리스너 소실되는 현상 수정
Antoliny0919 Apr 12, 2026
fb103e3
refactor: 불필요한 getter/setter 패턴 제거
Antoliny0919 Apr 12, 2026
1fb9134
refactor: 이미지 경로 모듈 import로 교체, fetch defaultOption 추가
Antoliny0919 Apr 12, 2026
f0af170
fix: totalPage를 초기화 하도록 수정
Antoliny0919 Apr 12, 2026
eb4b4ac
fix: 필요하지 않은 주석 제거
Antoliny0919 Apr 12, 2026
3c5e6d1
fix: 모달 bg가 전체화면 크기가 아닌 현상 수정
Antoliny0919 Apr 13, 2026
7122838
fix: setup 네이밍 형식을 일관되게 수정
Antoliny0919 Apr 13, 2026
2b9075b
style: 반응형 디자인 추가
Antoliny0919 Apr 13, 2026
ae4c2e7
fix: 영화 더 가져오기 이후 이전 영화의 이벤트리스너가 제거된 현상 수정
Antoliny0919 Apr 13, 2026
44cdccb
refactor: 중복되는 렌더링 로직 통합
Antoliny0919 Apr 13, 2026
1ab4e78
refactor: Renderer 메서드 순서 변경
Antoliny0919 Apr 13, 2026
b7f71d8
docs: 기능 요구사항 체크
Antoliny0919 Apr 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,4 @@ dist-ssr
*.sw?

.env
cypress/screenshots
50 changes: 50 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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] 상수분리
203 changes: 154 additions & 49 deletions cypress/e2e/spec.cy.ts
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

몇 가지 빠진 시나리오가 있는 것 같아요. "유저가 이 앱에서 할 수 있는 모든 행동"을 기준으로 테스트가 있는지 점검해보면 좋을 것 같습니다!

Original file line number Diff line number Diff line change
@@ -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: "타노스를 조심해",
Expand All @@ -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,
});

Expand All @@ -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("/");
});

Expand Down Expand Up @@ -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",
"영화 정보를 불러오는 데 실패했습니다.",
"오류가 발생했습니다. 다시 시도해주세요.",
);
});
});
Expand All @@ -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("검색 결과가 없을 때 안내 메시지가 렌더링된다", () => {
Expand Down Expand Up @@ -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",
"영화 정보를 불러오는 데 실패했습니다.",
"오류가 발생했습니다. 다시 시도해주세요.",
);
});
});
Expand All @@ -210,7 +208,7 @@ describe("영화 리뷰 앱", () => {

cy.get(".notice-text").should(
"contain.text",
"영화 정보를 불러오는 데 실패했습니다.",
"오류가 발생했습니다. 다시 시도해주세요.",
);
});

Expand All @@ -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");
});
});
});
Loading