Skip to content
Open
Show file tree
Hide file tree
Changes from 39 commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
724ba60
docs: 기능 구현 목록 추가
hjkim0905 Mar 31, 2026
2a77102
feat: 초기 렌더링 UI 완성 & API 요청 테스트 완료
hjkim0905 Mar 31, 2026
84c4ce3
feat: 더 보기 버튼 클릭 핸들러 완성
hjkim0905 Mar 31, 2026
2bef250
feat: 검색어 기반 패칭 완성
hjkim0905 Mar 31, 2026
287c9cf
feat: 검색 결과 페이징 구현
hjkim0905 Apr 1, 2026
d7def81
feat: 스켈레톤 이미지 처리 & 배너 DOM 제거 및 추가
hjkim0905 Apr 1, 2026
462c588
feat: 더보기 버튼 예외 처리 & 결과 없음 처리 구현
hjkim0905 Apr 1, 2026
95f87b7
refactor: AppState 분리, Skeleton UI 추가 및 movieRenderer 리팩토링
hjkim0905 Apr 2, 2026
34af311
test: E2E 테스트 구현
hjkim0905 Apr 2, 2026
6b8f71d
feat: 검색 UI 및 전체 스타일 개선
hjkim0905 Apr 2, 2026
f1e0003
docs: 리드미 수정
hjkim0905 Apr 2, 2026
288df06
fix: GitHub Pages 배포 시 CSS·이미지 경로 및 검색 버그 수정
hjkim0905 Apr 2, 2026
09a995a
docs: add application specs
hjkim0905 Apr 4, 2026
263a7d9
docs: update README with module relationship diagram
hjkim0905 Apr 4, 2026
31475e8
chore: remove console.log()
hjkim0905 Apr 4, 2026
5007a55
refactor: create MovieResponse type interface
hjkim0905 Apr 4, 2026
7677998
refacotr: use try&catch to handle error message
hjkim0905 Apr 4, 2026
e65f090
test: use mock test instead of real API fetch
hjkim0905 Apr 4, 2026
46facd9
refactor: create event handlers with names
hjkim0905 Apr 4, 2026
6175c37
refactor: remove export statement for renderBanner()
hjkim0905 Apr 4, 2026
41498bc
refactor: replace # prefix with TypeScript private keyword
hjkim0905 Apr 5, 2026
3e83ddd
refactor: resolve any type inference and replace let with const for m…
hjkim0905 Apr 5, 2026
eb067d9
refactor: separate error messages between API client and UI layer
hjkim0905 Apr 5, 2026
c206614
test: add tests for rejects of promises
hjkim0905 Apr 5, 2026
a6dc551
docs: add module relationship diagram
hjkim0905 Apr 7, 2026
aca4d5b
docs: add feature implementation list
hjkim0905 Apr 7, 2026
9a9adc9
feat: add moodal open/close event handler & separate event handlers i…
hjkim0905 Apr 7, 2026
bfba58b
feat: fetch and display movie details in modal via API
hjkim0905 Apr 8, 2026
a4396a7
chore: add Event Handler for opening modal when click detail button i…
hjkim0905 Apr 8, 2026
8688175
feat: implement hover, click events to the star images & create StarR…
hjkim0905 Apr 9, 2026
ef1c0c4
feat: save and restore star ratings per movie using localStorage
hjkim0905 Apr 9, 2026
9ce1f8e
feat: replace load-more button with infinite scroll using Intersectio…
hjkim0905 Apr 10, 2026
5ef2a23
refactor: extract RatingStorage interface and inject it into StarRating
hjkim0905 Apr 10, 2026
793efd9
feat: apply responsive design with media queries for tablet and mobile
hjkim0905 Apr 10, 2026
6591cc1
fix: fix modal close image not appearing issue when deployed
hjkim0905 Apr 10, 2026
90ab548
refactor: extract inline event callbacks into named handler methods i…
hjkim0905 Apr 11, 2026
efc47a5
test: add cypress e2e tests for movie list, modal, and rating
hjkim0905 Apr 11, 2026
281b2f2
docs: update readme todo
hjkim0905 Apr 11, 2026
b8bfe2b
Merge upstream-hjkim0905 into step2 to resolve PR conflicts
hjkim0905 Apr 11, 2026
d04306f
refactor: move event registration into SearchHandler and ModalHandler
hjkim0905 Apr 13, 2026
a7d53aa
chore: change class name SearchHandler to MovieBrowseHandler
hjkim0905 Apr 13, 2026
c9d585e
refactor: move initial movie load into MovieBrowseHandler and fix dou…
hjkim0905 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
53 changes: 41 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,32 @@ FE 레벨1 영화 리뷰 미션

- [x] 결과 없음 처리: 검색 결과가 존재하지 않을 경우 "검색 결과가 없습니다"라는 안내 메시지를 출력합니다.

3. 영화 상세 정보 조회

- [x] 모달 열기: 영화 포스터 또는 제목 클릭 시 상세 정보 모달 창을 표시합니다.

- [x] 모달 닫기: ESC 키 입력 또는 모달 외부 클릭 시 모달을 닫습니다.

- [x] 상세 정보 표시: API에서 제공하는 항목(제목, 장르, 줄거리, 평점 등)을 모달에 표시합니다.

4. 별점 매기기

- [x] 별점 입력: 사용자가 영화에 대해 1~5개의 별(2점 단위, 최대 10점)을 선택하여 별점을 줄 수 있습니다.

- [x] 별점 유지: 새로고침 후에도 사용자가 남긴 별점이 유지됩니다. (localStorage 사용)

- [x] 별점 표시: 2점=최악이에요, 4점=별로에요, 6점=보통이에요, 8점=재미있어요, 10점=명작이에요

5. UI/UX 개선

- [x] 반응형 레이아웃: 디바이스 너비에 따라 영화 목록과 모달 레이아웃이 유동적으로 조절됩니다.

- [x] 무한 스크롤: '더 보기' 버튼 방식을 무한 스크롤 방식으로 전환합니다. 화면 끝에 도달하면 다음 20개를 자동으로 불러옵니다.

6. E2E 테스트

- [x] 핵심 사용자 시나리오를 정의하고 E2E 테스트를 작성합니다.

---

## 주요 스펙 및 구현 결정사항
Expand All @@ -44,20 +70,23 @@ FE 레벨1 영화 리뷰 미션
- 검색 결과도 인기 영화 목록과 동일하게 페이지네이션이 적용됩니다.
- 검색 결과가 없는 경우 "검색 결과가 없습니다." 메시지를 표시합니다.

### 오류 처리
### 영화 상세 정보 조회

오류가 발생하는 경우를 다음과 같이 정의하고 대응했습니다.
- 영화 포스터 또는 제목 클릭 시 모달 창이 열립니다.
- ESC 키 또는 모달 외부 클릭으로 모달을 닫을 수 있습니다.
- API에서 제공하는 제목, 장르, 줄거리, 평점 등의 항목을 표시합니다.

| 오류 케이스 | 처리 방식 |
| ---------------------------- | ------------------------------------------------------------------------------- |
| 인기 영화 API 호출 실패 | `alert()`으로 사용자에게 직접 알림 |
| 검색 API 호출 실패 | `alert()`으로 사용자에게 직접 알림 |
| 영화 포스터 이미지 로드 실패 | 대체 이미지(`no_image.png`)로 교체 후 Skeleton 제거 |
| poster_path가 null인 경우 | TMDB가 200으로 응답하지만 이미지 로드 실패로 처리하여 동일하게 대체 이미지 적용 |
### 별점 매기기

`alert()`을 선택한 이유는 API 호출 실패는 사용자가 즉시 인지해야 하는 상황이라고 판단했기 때문입니다.
- 별점은 5개(최대 10점)로 구성되며 1개당 2점입니다.
- localStorage에 저장하여 새로고침 후에도 별점이 유지됩니다.

---
### 무한 스크롤

- 기존 '더 보기' 버튼 방식을 무한 스크롤로 전환합니다.
- 인기 영화 목록 및 검색 결과 모두 무한 스크롤이 적용됩니다.
- 마지막 페이지 도달 시 추가 요청을 중단합니다.

### 반응형 레이아웃

## 모듈 관계도
<img width="1261" height="696" alt="Image" src="https://github.com/user-attachments/assets/de6966be-0f1e-46de-a114-a4e4bd8a0ada" />
- Figma 시안을 기준으로 디바이스 너비에 따라 영화 목록과 모달의 레이아웃을 조절합니다.
98 changes: 98 additions & 0 deletions cypress/e2e/modal.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
describe("모달 열기 테스트", () => {
beforeEach(() => {
cy.intercept("GET", "**/movie/popular**", { fixture: "movies.json" }).as(
"getMovies",
);
cy.intercept("GET", "**/movie/1001**", { fixture: "moviedetail.json" }).as(
"getMovieDetail",
);
cy.visit("http://localhost:5173");
cy.wait("@getMovies");
});

it("영화 아이템을 클릭하면 모달이 열린다", () => {
cy.get(".thumbnail-list li").first().find(".item").click();
cy.wait("@getMovieDetail");
cy.get("#modalBackground").should("have.class", "active");
cy.get(".modal-container h2").should("contain", "영화 1");
});
});

describe("모달 닫기 테스트", () => {
beforeEach(() => {
cy.intercept("GET", "**/movie/popular**", { fixture: "movies.json" }).as(
"getMovies",
);
cy.intercept("GET", "**/movie/1001**", { fixture: "moviedetail.json" }).as(
"getMovieDetail",
);
cy.visit("http://localhost:5173");
cy.wait("@getMovies");
cy.get(".thumbnail-list li").first().find(".item").click();
cy.wait("@getMovieDetail");
});

it("닫기 버튼 클릭 시 모달이 닫힌다", () => {
cy.get("#closeModal").click();
cy.get("#modalBackground").should("not.have.class", "active");
cy.get(".modal-container").should("not.exist");
});

it("Escape 키 입력 시 모달이 닫힌다", () => {
cy.get("body").type("{esc}");
cy.get("#modalBackground").should("not.have.class", "active");
cy.get(".modal-container").should("not.exist");
});

it("배경 클릭 시 모달이 닫힌다", () => {
cy.get("#modalBackground").click({ force: true });
cy.get("#modalBackground").should("not.have.class", "active");
cy.get(".modal-container").should("not.exist");
});
});

describe("모달 반응형 테스트 - 태블릿", () => {
beforeEach(() => {
cy.viewport(768, 1024);
cy.intercept("GET", "**/movie/popular**", { fixture: "movies.json" }).as(
"getMovies",
);
cy.intercept("GET", "**/movie/1001**", { fixture: "moviedetail.json" }).as(
"getMovieDetail",
);
cy.visit("http://localhost:5173");
cy.wait("@getMovies");
cy.get(".thumbnail-list li").first().find(".item").click();
cy.wait("@getMovieDetail");
});

it("태블릿 화면에서 모달이 하단 고정으로 표시된다", () => {
cy.get("#modalBackground").should("have.css", "align-items", "flex-end");
cy.get(".modal").should("have.css", "width", "768px");
});

it("태블릿 화면에서 포스터와 설명이 세로 정렬된다", () => {
cy.get(".modal-container").should("have.css", "flex-direction", "column");
cy.get(".modal-image img").should("have.css", "width", "160px");
});
});

describe("모달 반응형 테스트 - 모바일", () => {
beforeEach(() => {
cy.viewport(375, 667);
cy.intercept("GET", "**/movie/popular**", { fixture: "movies.json" }).as(
"getMovies",
);
cy.intercept("GET", "**/movie/1001**", { fixture: "moviedetail.json" }).as(
"getMovieDetail",
);
cy.visit("http://localhost:5173");
cy.wait("@getMovies");
cy.get(".thumbnail-list li").first().find(".item").click();
cy.wait("@getMovieDetail");
});

it("모바일 화면에서 포스터 이미지가 숨겨진다", () => {
cy.get(".modal-image").should("have.css", "display", "none");
});
});
46 changes: 28 additions & 18 deletions cypress/e2e/spec.cy.ts → cypress/e2e/movie.cy.ts
Original file line number Diff line number Diff line change
@@ -1,40 +1,44 @@
describe("인기영화 렌더링 테스트", () => {
beforeEach(() => {
cy.visit("localhost:5173");
});

it("웹에 접근을 하면 인기 영화 20개를 랜더링 한다", () => {
// page=2 자동 로드를 막아 20개만 렌더링되도록 intercept
cy.intercept(
"GET",
"https://api.themoviedb.org/3/movie/popular?language=ko-KR&page=2",
{ body: { results: [], total_pages: 1 } },
);
cy.visit("localhost:5173");
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Cypress baseUrl 설정과 visit 사용 패턴 확인
configs="$(fd -i 'cypress.config.*')"
echo "== Cypress config files =="
echo "$configs"

if [ -n "$configs" ]; then
  echo "== baseUrl declarations =="
  rg -n "baseUrl" $configs || true
fi

echo "== cy.visit usages in e2e specs =="
rg -nP 'cy\.visit\(' --type=ts cypress/e2e

Repository: woowacourse/javascript-movie-review

Length of output: 957


🏁 Script executed:

cat -n cypress.config.ts

Repository: woowacourse/javascript-movie-review

Length of output: 316


cy.visit() 호출에서 프로토콜 누락으로 환경 의존성 발생

cypress.config.tsbaseUrl이 설정되지 않았으므로, cy.visit()는 항상 완전한 URL(프로토콜 포함)을 필요로 합니다. 현재 라인 9, 14, 47에서 cy.visit("localhost:5173")처럼 프로토콜 없이 작성되어 있어 테스트 실행이 불안정할 수 있습니다.

  • Cypress는 프로토콜이 없으면 URL을 제대로 해석하지 못할 수 있습니다.
  • 같은 파일 내 다른 라인들과 다른 스펙 파일들은 모두 "http://localhost:5173"로 일관되게 작성되어 있습니다.

두 가지 접근이 가능합니다:

  1. cypress.config.tsbaseUrl을 설정하고 cy.visit("/")로 통일하는 방식
  2. 모든 cy.visit() 호출에 프로토콜을 명시하여 일관성 있게 작성하는 방식

어느 방식을 선택하든 현재 3개 라인의 불일치를 해결해야 합니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cypress/e2e/movie.cy.ts` at line 9, In cypress/e2e/movie.cy.ts the cy.visit
calls (the cy.visit(...) invocations) omit the protocol causing
environment-dependent failures; fix by either setting baseUrl in
cypress.config.ts and replacing those cy.visit("localhost:5173") calls with
cy.visit("/") across the file, or update each cy.visit invocation (the three
cy.visit(...) occurrences) to include the full URL with protocol (e.g.,
"http://localhost:5173") so the tests are consistent and stable.

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

it("인기 영화 화면에서 더보기 버튼을 누르면 인기 영화 20개를 추가로 렌더링 한다", () => {
cy.get("#load-movie-button").click();
it("인기 영화 화면에서 화면의 끝에 도달하면 인기 영화 20개를 추가로 렌더링 한다", () => {
cy.visit("localhost:5173");
cy.get("#scroll-sentinel").scrollIntoView();
cy.get(".thumbnail-list li").should("have.length", 40);
});
});

describe("인기 영화 더보기 버튼이 숨겨지는지 테스트", () => {
describe("인기 영화 무한스크롤 테스트", () => {
beforeEach(() => {
cy.intercept(
"GET",
"https://api.themoviedb.org/3/movie/popular?language=en-US&page=1",
"https://api.themoviedb.org/3/movie/popular?language=ko-KR&page=1",
{ fixture: "movies.json" },
).as("getMovies");

cy.intercept(
"GET",
"https://api.themoviedb.org/3/movie/popular?language=en-US&page=2",
"https://api.themoviedb.org/3/movie/popular?language=ko-KR&page=2",
{ fixture: "movies2.json" },
).as("getMoviesPage2");

cy.visit("http://localhost:5173");
});

it("마지막 페이지 도달 시 더보기 버튼이 사라진다", () => {
it("마지막 페이지 도달 시 스크롤 시에 영화를 더 불러오지 않는다.", () => {
cy.wait("@getMovies");
cy.get("#load-movie-button").click();
cy.get("#scroll-sentinel").scrollIntoView();
cy.wait("@getMoviesPage2");
cy.get("#load-movie-button").should("have.css", "display", "none");
cy.get(".thumbnail-list li").should("have.length", 40);
});
});

Expand All @@ -57,8 +61,14 @@ describe("검색영화 렌더링 테스트", () => {
});
Comment on lines +50 to +61
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

검색 시나리오가 실 API에 의존해 테스트가 흔들릴 수 있습니다

Line 50-61은 intercept 없이 외부 응답에 의존합니다. CI 안정성을 위해 검색 결과 유/무 시나리오도 fixture 기반으로 고정하는 게 좋습니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cypress/e2e/movie.cy.ts` around lines 50 - 61, These tests currently call the
real search API; add cy.intercept fixtures to stub responses for both scenarios
before triggering the UI: intercept the search API (match the same request your
app uses, e.g., GET /api/search or path with query) and return a fixture like
"search-harry-potter.json" for the "Harry Potter" test and "search-empty.json"
for the no-results test; place the intercepts before using
cy.get(".search-input")/cy.get(".search-button") (or cy.press("Enter")), alias
the routes (cy.as('search')) and optionally cy.wait('@search') before asserting
against ".thumbnail-list li" and "#no-result" so CI is stable and deterministic.

});

describe("검색 영화 더보기 버튼이 숨겨지는지 테스트", () => {
describe("검색 영화 무한스크롤 테스트", () => {
beforeEach(() => {
cy.intercept(
"GET",
"https://api.themoviedb.org/3/movie/popular?language=ko-KR&page=2",
{ body: { results: [], total_pages: 1 } },
);

cy.intercept("GET", "**/search/movie*page=1*", {
fixture: "movies.json",
}).as("getMovies");
Expand All @@ -70,21 +80,21 @@ describe("검색 영화 더보기 버튼이 숨겨지는지 테스트", () => {
cy.visit("http://localhost:5173");
});

it("마지막 페이지 도달 시 더보기 버튼이 사라진다", () => {
it("마지막 페이지 도달 시 스크롤 시에 영화를 더 불러오지 않는다.", () => {
cy.get(".search-input").type("영화");
cy.get(".search-input").type("{enter}");
cy.wait("@getMovies");
cy.get("#load-movie-button").click();
cy.get("#scroll-sentinel").scrollIntoView();
cy.wait("@getMoviesPage2");
cy.get("#load-movie-button").should("have.css", "display", "none");
cy.get(".thumbnail-list li").should("have.length", 40);
});
});

describe("Skeleton UI 테스트", () => {
it("이미지 로드 전 스켈레톤 UI가 표시된다", () => {
cy.intercept(
"GET",
"https://api.themoviedb.org/3/movie/popular?language=en-US&page=1",
"https://api.themoviedb.org/3/movie/popular?language=ko-KR&page=1",
{ fixture: "movies.json" },
).as("getMovies");

Expand All @@ -110,7 +120,7 @@ describe("Skeleton UI 테스트", () => {
it("이미지 로드 실패 시 스켈레톤 UI가 제거되고 대체 이미지가 표시된다", () => {
cy.intercept(
"GET",
"https://api.themoviedb.org/3/movie/popular?language=en-US&page=1",
"https://api.themoviedb.org/3/movie/popular?language=ko-KR&page=1",
{ fixture: "movies.json" },
).as("getMovies");

Expand Down
49 changes: 49 additions & 0 deletions cypress/e2e/rating.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
describe("로컬스토리지 평점 저장 테스트", () => {
beforeEach(() => {
cy.clearLocalStorage();
cy.intercept("GET", "**/movie/popular**", { fixture: "movies.json" }).as(
"getMovies",
);
cy.intercept("GET", "**/movie/1001**", { fixture: "moviedetail.json" }).as(
"getMovieDetail",
);
cy.visit("http://localhost:5173");
cy.wait("@getMovies");
cy.get(".thumbnail-list li").first().find(".item").click();
cy.wait("@getMovieDetail");
});

it("별점을 클릭하면 로컬스토리지에 저장된다", () => {
cy.get(".my-rate-stars .my-star").eq(3).click();
cy.window()
.its("localStorage")
.invoke("getItem", "movieRatings")
.then((val) => {
if (val) {
expect(JSON.parse(val)).to.deep.equal({ "1001": 8 });
}
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

로컬스토리지 assertion이 조건문에 가려져 실패를 놓칠 수 있습니다

Line 21-24는 valnull이어도 테스트가 통과합니다. 저장 실패를 확실히 잡으려면 먼저 val 존재를 단정한 뒤 파싱 검증으로 이어가세요.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cypress/e2e/rating.cy.ts` around lines 18 - 25, The test currently skips
assertions when localStorage.getItem("movieRatings") returns null; change the
check to explicitly assert existence first and only then parse and deep-equal
the value. Specifically, after cy.window().its("localStorage").invoke("getItem",
"movieRatings"), add an assertion that the returned val is not null/undefined
(e.g., expect(val).to.exist) and then JSON.parse(val) and expect the parsed
object to.deep.equal({ "1001": 8 }), so the test fails if the item was never
stored.

});
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

아래 테스트("새로고침 후 별점 유지")가 사용자 관점에서 검증하고 있는데, 이 로컬스토리지 저장 테스트가 필요할까요?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

맞습니다! PR 본문에서도 고민했던 부분인데, 결론적으로 해당 테스트를 제거했습니다.

localStorage 직접 검증은 구현 세부사항을 테스트하는 것이고, "새로고침 후 별점 유지" 테스트가 이미 사용자 관점에서 동일한 동작을 검증하고 있어 중복이라고 판단했습니다. E2E 테스트의 목적이 사용자 시나리오 검증이라면 후자만으로 충분하다고 생각합니다!


it("새로고침 후 같은 영화 모달을 열면 저장된 평점이 유지된다", () => {
cy.get(".my-rate-stars .my-star").eq(3).click();
cy.reload();
cy.wait("@getMovies");
cy.get(".thumbnail-list li").first().find(".item").click();
cy.wait("@getMovieDetail");

cy.get(".my-rate-stars .my-star").each((star, index) => {
if (index <= 3) {
cy.wrap(star)
.should("have.attr", "src")
.and("include", "star_filled.png");
} else {
cy.wrap(star)
.should("have.attr", "src")
.and("include", "star_empty.png");
}
});
cy.get(".my-score-label").should("contain", "재미있어요");
cy.get(".my-rate-value").should("contain", "(8/10)");
});
});
13 changes: 13 additions & 0 deletions cypress/fixtures/moviedetail.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"id": 1001,
"title": "영화 1",
"poster_path": "/test0.jpg",
"backdrop_path": "/backdrop0.jpg",
"release_date": "2024-01-01",
"genres": [
{ "id": 28, "name": "액션" },
{ "id": 12, "name": "모험" }
],
"vote_average": 7.5,
"overview": "테스트용 영화 줄거리입니다."
}
Loading