Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
5c5fdcb
docs: 기능 구현 목록 추가
hjkim0905 Mar 31, 2026
ad981b6
feat: 초기 렌더링 UI 완성 & API 요청 테스트 완료
hjkim0905 Mar 31, 2026
f4adca4
feat: 더 보기 버튼 클릭 핸들러 완성
hjkim0905 Mar 31, 2026
4a34844
feat: 검색어 기반 패칭 완성
hjkim0905 Mar 31, 2026
5c4ea88
feat: 검색 결과 페이징 구현
hjkim0905 Apr 1, 2026
ee8a5bd
feat: 스켈레톤 이미지 처리 & 배너 DOM 제거 및 추가
hjkim0905 Apr 1, 2026
f76602a
feat: 더보기 버튼 예외 처리 & 결과 없음 처리 구현
hjkim0905 Apr 1, 2026
56a6a32
refactor: AppState 분리, Skeleton UI 추가 및 movieRenderer 리팩토링
hjkim0905 Apr 2, 2026
11f5018
test: E2E 테스트 구현
hjkim0905 Apr 2, 2026
657ab71
feat: 검색 UI 및 전체 스타일 개선
hjkim0905 Apr 2, 2026
e915275
docs: 리드미 수정
hjkim0905 Apr 2, 2026
39c1ff7
fix: GitHub Pages 배포 시 CSS, 이미지 경로 수정
TH-97 Apr 2, 2026
43482eb
fix: GitHub Pages 배포 시 이모지 로고 경로 수정
TH-97 Apr 2, 2026
561807d
fix: 재검색 시 더보기 버튼이 숨겨지는 버그 수정
TH-97 Apr 2, 2026
25c2a57
refactor: createHtml.ts 분리로 HTML 생성과 DOM 삽입 책임 분리
TH-97 Apr 5, 2026
220e197
refactor: 검색바와 배너 영역 분리로 replaceBanner 제거
TH-97 Apr 5, 2026
d064e90
refactor: DOM 중앙화 및 불필요한 상태 제거
TH-97 Apr 5, 2026
6996cb7
refactor: 관심사 분리 및 데이터 캡슐화
TH-97 Apr 5, 2026
5b2fa1e
test: 이미지 응답 차단 방식으로 스켈레톤 UI 테스트 구현
TH-97 Apr 5, 2026
b5a19f5
fix: fetch 네트워크 오류 처리 추가
TH-97 Apr 5, 2026
abdaf6f
test: API 단위 테스트 제거
TH-97 Apr 5, 2026
1827722
feat: loading 상태 추가로 Race Condition 방지
TH-97 Apr 5, 2026
8e3822c
test: 정확성을 위한 테스트 코드 수정
TH-97 Apr 5, 2026
7138f1b
refactor: 아키텍처 재구성
TH-97 Apr 9, 2026
010fbc8
docs: README.md 추가 작성
TH-97 Apr 9, 2026
8bd99fa
feat: 모달창 띄우기 기능 구현
TH-97 Apr 9, 2026
7cbe2af
feat: 별점 클릭 저장 및 배너 자세히 보기 모달 연결
TH-97 Apr 11, 2026
48b564c
refactor: 더보기 버튼을 무한 스크롤로 교체
TH-97 Apr 11, 2026
56ba9c9
style: 반응형 레이아웃 및 무한 스크롤 UI 적용
TH-97 Apr 11, 2026
27b27b8
style: 모바일 반응형 모달 레이아웃 적용
TH-97 Apr 11, 2026
d019e32
test: e2e 테스트 fixture 개선 및 무한 스크롤/모달/별점 테스트 추가
TH-97 Apr 11, 2026
9a28743
test: 네트워크 연결 실패 시 에러 알림 e2e 테스트 추가
TH-97 Apr 13, 2026
20805b4
refactor: 미사용 setSectionTitle 함수 주석 처리
TH-97 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
49 changes: 49 additions & 0 deletions CLAUDE.md
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()`는 데이터 표시만 담당.
Comment on lines +37 to +39
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 | 🟡 Minor

MovieController 설명은 현재 구현 기준으로 다시 맞춰두는 편이 좋겠습니다.

Line 38은 #createNewRequest(), #applyResult(), #handleError() 같은 private method 구조를 전제로 설명하지만, 제공된 src/presentation/MovieController.ts:17-42 스니펫은 함수 기반 createNewRequest / handleSuccess / handleError 흐름입니다. 이 문서가 작업 지침으로 쓰이는 파일이라면 현재 코드와 맞춰 두는 편이 안전합니다.

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

In `@CLAUDE.md` around lines 37 - 39, Update the MovieController description to
match the current function-based implementation: replace references to private
methods `#createNewRequest()`, `#applyResult()`, `#handleError()` with the
actual exported/defined functions `createNewRequest`, `handleSuccess`, and
`handleError` as implemented in `src/presentation/MovieController.ts` (snippet
lines 17-42); also adjust wording to describe the function-based flow (event →
domain command → success/error handlers → renderer) and ensure `MovieRenderer`
responsibilities (start/stop loading called by controller, `render()` only
displays data) remain accurate.

- `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`)가 없어야 한다.
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
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 | 🟡 Minor

체크리스트 번호 표기를 정리해 주세요.

Line 50이 다시 3.으로 시작하고, Line 54는 4 뒤 마침표가 없어 문서 흐름이 끊겨 보입니다.

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

In `@README.md` around lines 50 - 55, The numbering in the checklist is
inconsistent: change the duplicated "3. UI/UX 개선" and the "4 E2E 구현" entry to a
continuous, properly punctuated sequence (e.g., "3. UI/UX 개선" and "4. E2E 구현"),
and ensure all numbered headings include the trailing period so the list flows
correctly; update the README.md lines that contain the strings "3. UI/UX 개선" and
"4 E2E 구현" accordingly.

- [x] 무한스크롤 테스트
- [x] 자세히 보기 테스트
220 changes: 180 additions & 40 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.

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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 테스트", () => {
Expand Down Expand Up @@ -136,3 +212,67 @@ describe("Skeleton UI 테스트", () => {
});
});
});

// describe("인기 영화 더보기 버튼이 숨겨지는지 테스트", () => {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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("별점 클릭 시 별점이 저장된다")
// });
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": 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": "드라마" }
]
}
Loading