Skip to content
Open
Show file tree
Hide file tree
Changes from 31 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
87a0813
feat: 계층형 설계에 맞추어 component파일 위치 수정
geongyu09 Apr 7, 2026
e8532b9
feat: selector를 통해 dom 요소를 가져오는 유틸 함수 구현
geongyu09 Apr 7, 2026
379c8ce
feat: 요소에 이벤트 리스너를 다는 유틸 함수 구현
geongyu09 Apr 7, 2026
e5e9673
refactor: 계층형 설계에 맞추어 render 파일 위치 수정
geongyu09 Apr 7, 2026
63f4a92
feat: 도메인과 연관된 요소를 가져오는 로직 구현
geongyu09 Apr 7, 2026
1f7fbcb
feat: 홈 화면과 관련된 작은 단위의 비즈니스 함수 구현
geongyu09 Apr 7, 2026
357b16f
refactor: 홈 화면을 그리는 함수 계층화 리팩토링
geongyu09 Apr 8, 2026
1ce9313
refactor: 검색 화면을 그리는 함수의 계층화 리팩토링
geongyu09 Apr 8, 2026
dbe6bee
refactor: 상태 로직을 계층에 맞추어 리팩토링
geongyu09 Apr 9, 2026
5fe022b
refactor: 검색 폼 로직을 계층에 맞추어 리팩토링
geongyu09 Apr 9, 2026
8b77eec
feat: api 함수에 DTO 계층 추가
geongyu09 Apr 9, 2026
609b4e2
chore: step1 readme를 별도의 파일로 분리
geongyu09 Apr 9, 2026
543fde0
docs: step2 기능 요구사항 분석 작성
geongyu09 Apr 9, 2026
38e4def
docs: step2 구현 사항 작성
geongyu09 Apr 9, 2026
5e89a07
feat: 기존의 더 보기 버튼 제거
geongyu09 Apr 9, 2026
834c964
feat: 영화 목록에 무한 스크롤 적용
geongyu09 Apr 9, 2026
098cf82
refactor: 계층에 맞추어 page 로직 리팩토링
geongyu09 Apr 9, 2026
18ce931
feat: 영화 상세정보 조회 모달 ui 구현
geongyu09 Apr 9, 2026
42ef4f4
feat: 영화 상세 정보 조회 API 로직 구현
geongyu09 Apr 10, 2026
71bf02b
feat: 영화 포스터 혹은 제목을 클릭하면 열리도록 기능 추가
geongyu09 Apr 10, 2026
b29afe5
feat: 영화 상세 정보 조회 API 연동
geongyu09 Apr 10, 2026
5853e1e
refactor: 영화 상세 모달 관련 계층화 리팩토링
geongyu09 Apr 10, 2026
f5fe961
feat: ESC 키를 누르거나 모달 외부(dimmed), 혹은 x 버튼을 클릭하면 영화 모달이 닫히는 기능 추가
geongyu09 Apr 11, 2026
32aac63
feat: 별점 이벤트 핸들러 구현
geongyu09 Apr 12, 2026
7f7c831
feat: 별점을 클릭해 별점을 매길 수 있는 기능 추가
geongyu09 Apr 12, 2026
4c069c5
feat: 반응형 UI 적용
geongyu09 Apr 13, 2026
88c1238
chore: 더 이상 사용하지 않는 templates 파일 제거
geongyu09 Apr 13, 2026
ab45e91
fix: fetcher에서 에러 발생시 새롭게 에러를 던지지 않도록 수정
geongyu09 Apr 13, 2026
b05d225
fix: 줄거리를 올바르게 가져오지 못하던 버그 수정
geongyu09 Apr 13, 2026
7ffd5fc
test: E2E 테스트 추가
geongyu09 Apr 13, 2026
9971f9b
fix: 검색 결과가 없는 경우 무한히 무한스크롤 되던 버그 수정
geongyu09 Apr 13, 2026
2205e0a
fix: getMovieDetails 함수의 fetcher 타입을 MovieDetail로 수정
geongyu09 Apr 13, 2026
0452e20
refactor: ui 코드 중 추상화가 맞지 않던 부분 수정
geongyu09 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
50 changes: 2 additions & 48 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,51 +2,5 @@

FE 레벨1 영화 리뷰 미션

## 기능 요구사항 분석

### 홈 페이지

- [x] 홈 화면 UI 구현
- [x] 검색 레이아웃
- [x] 섹션 헤더
- [x] 더보기 버튼
- [x] 탭(인기순, 평점순, 상영 예정) 제거

- [x] 영화목록 데이터를 API를 통해 가져온다.
- [x] 영화목록을 API 데이터 기반으로 렌더링한다.
- [x] 더보기 버튼을 누르면 다음 영화목록 20개를 보여준다.
- [x] 영화 목록을 불러오는 동안 Skeleton UI를 보여준다.

### 검색 페이지

- [x] 홈 화면에서 상단 검색바에 검색어를 입력 후 제출하면 검색 결과 페이지로 이동한다.
- [x] 엔터키를 눌러 검색할 수 있다.
- [x] 검색 버튼을 클릭하여 검색할 수 있다.
- [x] 검색 이후에 더 보기 클릭시 다음 영화 검색 결과 20개를 보여준다.

### API 오류시

- [x] 사용자를 위한 오류 메시지를 띄워준다. (UI 자율)

### 추후 구현 사항

- [x] 영화 데이터 한국어버전 가져오기
- [x] 평점 소수점 한자리로 보이도록 수정

### 리팩토링 사항

- [x] Component에 사용되는 도메인 상수화
- [x] API에 사용되는 도메인 상수화
- [x] main에 존재하는 Render 관련 로직 분리
- [x] 첫 렌더링 시 헤딩요소가 헤더와 겹치는 문제
- [x] loadMoreMovies에 사용되는 onError가 하드코딩
- [x] 상태 로직 분리(page...)

### E2E 테스트 목록

- [x] 영화 배너가 렌더링되었는지 테스트
- [x] 초기 영화 데이터가 렌더링되었는지 테스트
- [x] 더 보기를 클릭했을때 영화 데이터가 추가적으로 렌더링되는지 테스트
- [x] 스켈레톤 UI가 로딩중에 보이는지 테스트
- [x] 영화 검색시 필터링된 영화가 보이는지 테스트
- [x] 영화 검색시 검색어가 섹션 헤딩에 보이는지 테스트
[step1.md](./step1.md)
[step2.md](./step2.md)
258 changes: 177 additions & 81 deletions cypress/e2e/spec.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,24 @@ const createMoviesResponse = (count: number, page: number = 1) => ({
total_results: 10000,
});

const createMockMovieDetail = (id: number) => ({
id,
title: `어벤져스 ${id}`,
poster_path: `/avengers${id}.jpg`,
vote_average: 7.5,
backdrop_path: `/backdrop${id}.jpg`,
genres: [{ id: 28, name: "액션" }],
original_language: "ko",
original_title: `어벤져스 ${id}`,
overview: "타노스를 조심해",
popularity: 22.4343,
release_date: "2026-04-01",
tagline: "어벤져스 어셈블",
video: false,
vote_count: 1000,
adult: false,
});

describe("영화 리뷰 앱", () => {
beforeEach(() => {
cy.intercept("GET", "**/movie/popular*", createMoviesResponse(20)).as(
Expand Down Expand Up @@ -60,64 +78,20 @@ describe("영화 리뷰 앱", () => {
cy.wait("@getPopularMoviesDelayed");
cy.get(".thumbnail-list li.skeleton").should("not.exist");
});
});

describe("더 보기", () => {
it("더 보기 클릭 시 영화가 20개 추가 렌더링된다", () => {
it("스크롤 시 추가 영화 목록이 로드된다", () => {
cy.wait("@getPopularMovies");
cy.get(".thumbnail-list li").should("have.length", 20);

cy.intercept("GET", "**/movie/popular*", createMoviesResponse(20, 2)).as(
"getMoreMovies",
);

cy.get(".load-more-button").click();
cy.get(".load-more-inView").scrollIntoView();
cy.wait("@getMoreMovies");

cy.get(".thumbnail-list li").should("have.length", 40);
});

it("더 보기 API 실패 시 에러 메시지가 렌더링된다", () => {
cy.wait("@getPopularMovies");

cy.intercept("GET", "**/movie/popular*", { statusCode: 500 }).as(
"getMoreMoviesError",
);

cy.get(".load-more-button").click();
cy.wait("@getMoreMoviesError");

cy.get(".notice-text").should(
"contain.text",
"영화 정보를 불러오는 데 실패했습니다.",
);
});

it("마지막 페이지 도달 시 더 보기 버튼이 숨겨진다", () => {
cy.wait("@getPopularMovies");

cy.intercept("GET", "**/movie/popular*", createMoviesResponse(7, 2)).as(
"getLastPageMovies",
);

cy.get(".load-more-button").click();
cy.wait("@getLastPageMovies");

cy.get(".load-more-button").should("not.be.visible");
});

it("더 보기 로딩 중에는 더 보기 버튼이 숨겨진다", () => {
cy.wait("@getPopularMovies");

cy.intercept("GET", "**/movie/popular*", (req) => {
req.reply({ delay: 500, body: createMoviesResponse(20, 2) });
}).as("getMoreMoviesDelayed");

cy.get(".load-more-button").click();
cy.get(".load-more-button").should("not.be.visible");

cy.wait("@getMoreMoviesDelayed");
cy.get(".load-more-button").should("be.visible");
});
});

describe("검색", () => {
Expand Down Expand Up @@ -218,27 +192,6 @@ describe("영화 리뷰 앱", () => {
);
});

it("검색 이후 더 보기 클릭 시 영화가 20개 추가 렌더링된다", () => {
cy.wait("@getPopularMovies");

cy.intercept("GET", "**/search/movie*", createMoviesResponse(20)).as(
"searchMovies",
);

cy.get(".search-form input").type("액션");
cy.get(".search-form").submit();
cy.wait("@searchMovies");

cy.intercept("GET", "**/search/movie*", createMoviesResponse(20, 2)).as(
"searchMoreMovies",
);

cy.get(".load-more-button").click();
cy.wait("@searchMoreMovies");

cy.get(".thumbnail-list li").should("have.length", 40);
});

it("검색 중 스켈레톤 UI가 표시된다", () => {
cy.wait("@getPopularMovies");

Expand Down Expand Up @@ -275,29 +228,55 @@ describe("영화 리뷰 앱", () => {
cy.get(".thumbnail-list li").should("have.length", 20);
cy.get("section > h2").should("have.text", "지금 인기 있는 영화");
});
});

it("검색 이후 더 보기 API 실패 시 에러 메시지가 렌더링된다", () => {
describe("검색 결과 무한 스크롤", () => {
it("검색 결과가 여러 페이지일 때 스크롤 시 추가 결과가 로드된다", () => {
cy.wait("@getPopularMovies");

cy.intercept("GET", "**/search/movie*", createMoviesResponse(20)).as(
"searchMovies",
);
cy.intercept("GET", "**/search/movie*", {
...createMoviesResponse(20),
total_pages: 3,
}).as("searchMovies");

cy.get(".search-form input").type("액션");
cy.get(".search-form").submit();
cy.wait("@searchMovies");
cy.get(".thumbnail-list li").should("have.length", 20);

cy.intercept("GET", "**/search/movie*", { statusCode: 500 }).as(
"searchMoreMoviesError",
);
cy.intercept("GET", "**/search/movie*", {
...createMoviesResponse(20, 2),
total_pages: 3,
}).as("searchMoreMovies");

cy.get(".load-more-button").click();
cy.wait("@searchMoreMoviesError");
cy.get(".load-more-inView").scrollIntoView();
cy.wait("@searchMoreMovies");

cy.get(".notice-text").should(
"contain.text",
"영화 정보를 불러오는 데 실패했습니다.",
);
cy.get(".thumbnail-list li").should("have.length", 40);
});

it("검색 결과 마지막 페이지 도달 후 스크롤해도 추가 요청이 발생하지 않는다", () => {
cy.wait("@getPopularMovies");

cy.intercept("GET", "**/search/movie*", {
...createMoviesResponse(5),
total_pages: 1,
}).as("searchLastPage");

cy.get(".search-form input").type("액션");
cy.get(".search-form").submit();
cy.wait("@searchLastPage");

let extraCallCount = 0;
cy.intercept("GET", "**/search/movie*", () => {
extraCallCount++;
});

cy.get(".load-more-inView").scrollIntoView();
cy.wait(500);
cy.then(() => {
expect(extraCallCount).to.equal(0);
});
});
});

Expand All @@ -316,4 +295,121 @@ describe("영화 리뷰 앱", () => {
);
});
});

describe("영화 상세 정보 모달", () => {
it("영화 포스터 또는 제목 클릭 시 영화 상세 정보 모달이 렌더링된다", () => {
cy.wait("@getPopularMovies");

cy.intercept("GET", /\/movie\/\d+/, createMockMovieDetail(1)).as(
"getMovieDetails",
);

cy.get(".thumbnail-list li:first-child").click();
cy.wait("@getMovieDetails");

cy.get(".modal-background").should("be.visible");
cy.get(".modal h2").should("have.text", "어벤져스 1");
});

it("영화 상세 정보 조회 API 실패 시 에러 메시지가 렌더링된다", () => {
cy.wait("@getPopularMovies");

cy.intercept("GET", /\/movie\/\d+/, { statusCode: 500 }).as(
"getMovieDetailsError",
);

cy.get(".thumbnail-list li:first-child").click();
cy.wait("@getMovieDetailsError");

cy.get(".notice-text").should(
"contain.text",
"영화 상세 정보를 불러오는 데 실패했습니다",
);
});
});

describe("영화 상세 정보 모달 닫기", () => {
beforeEach(() => {
cy.wait("@getPopularMovies");

cy.intercept("GET", /\/movie\/\d+/, createMockMovieDetail(1)).as(
"getMovieDetails",
);

cy.get(".thumbnail-list li:first-child").click();
cy.wait("@getMovieDetails");
cy.get(".modal-background").should("be.visible");
});

it("ESC 키 입력 시 모달이 닫힌다", () => {
cy.get("body").type("{esc}");
cy.get(".modal-background").should("not.exist");
});

it("모달 외부(dimmed) 클릭 시 모달이 닫힌다", () => {
cy.get(".modal-background").click({ force: true });
cy.get(".modal-background").should("not.exist");
});

it("X 버튼 클릭 시 모달이 닫힌다", () => {
cy.get(".close-modal").click();
cy.get(".modal-background").should("not.exist");
});
});

describe("별점", () => {
beforeEach(() => {
cy.clearLocalStorage();
cy.wait("@getPopularMovies");

cy.intercept("GET", /\/movie\/\d+/, createMockMovieDetail(1)).as(
"getMovieDetails",
);

cy.get(".thumbnail-list li:first-child").click();
cy.wait("@getMovieDetails");
});

it("모달 오픈 시 별점이 초기값(0/10)으로 표시된다", () => {
cy.get(".my-rating__point").should("have.text", "(0/10)");
});

it("별점 클릭 시 해당 별점으로 변경된다", () => {
cy.get('.my-rating__content img[data-rating-value="8"]').click();

cy.get(".my-rating__point").should("have.text", "(8/10)");
cy.get(".my-rating__content img")
.eq(0)
.should("have.attr", "src")
.and("include", "star_filled");
cy.get(".my-rating__content img")
.eq(3)
.should("have.attr", "src")
.and("include", "star_filled");
cy.get(".my-rating__content img")
.eq(4)
.should("have.attr", "src")
.and("include", "star_empty");
});

it("새로고침 후에도 매긴 별점이 유지된다", () => {
cy.get('.my-rating__content img[data-rating-value="6"]').click();
cy.get(".my-rating__point").should("have.text", "(6/10)");

cy.get(".close-modal").click();
cy.get(".modal-background").should("not.exist");

cy.reload();
cy.wait("@getPopularMovies");

cy.intercept("GET", /\/movie\/\d+/, createMockMovieDetail(1)).as(
"getMovieDetailsAgain",
);

cy.get(".thumbnail-list li:first-child").click();
cy.wait("@getMovieDetailsAgain");

cy.get(".my-rating__point").should("have.text", "(6/10)");
});
});
});
4 changes: 3 additions & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
<link rel="stylesheet" href="src/styles/main.css" />
<link rel="stylesheet" href="src/styles/tab.css" />
<link rel="stylesheet" href="src/styles/thumbnail.css" />
<link rel="stylesheet" href="src/styles/modal.css" />
<title>영화 리뷰</title>
</head>

Expand Down Expand Up @@ -42,7 +43,6 @@ <h1 class="logo">
<h2 class="home-section-heading"></h2>
<ul class="thumbnail-list"></ul>
</section>
<button class="primary load-more-button">더 보기</button>
</div>
</main>

Expand All @@ -51,6 +51,8 @@ <h2 class="home-section-heading"></h2>
<p><img src="src/images/woowacourse_logo.png" width="180" /></p>
</footer>
</div>

<!-- <div class="modal-background active" id="modalBackground"></div> -->
<script src="src/main.ts" type="module"></script>
</body>
</html>
Expand Down
Loading