Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
1b4f372
feat: 영화 목록을 받아오는 fetch 구현 및 클릭 이벤트 테스트
iftype Apr 8, 2026
7a9e41d
feat: Modal 구현
iftype Apr 9, 2026
a3960c0
feat: 별점을 제출하는 형식 정의
iftype Apr 9, 2026
0e15c68
feat: 로컬 스토리지 사용하여 평점기능추가
iftype Apr 9, 2026
3347c6d
refactor: Search Page 모달 적용
iftype Apr 9, 2026
4aebacb
refactor: 리스트 이벤트 위임으로 변경
iftype Apr 9, 2026
c52cd17
style: 반응형 레이아웃 설정
iftype Apr 9, 2026
f358ea2
feat: 무한 스크롤 구현
iftype Apr 9, 2026
9ed6647
feat: AP 중복 호출 문제 해결
iftype Apr 9, 2026
934b327
stype: 검색창 css 수정
iftype Apr 9, 2026
7b9e255
test: 스크롤로 바뀜에 따라 테스트 수정
iftype Apr 9, 2026
60cf7d4
feat: 페이지 캐싱적용
iftype Apr 9, 2026
cc3739f
fix: 페이지 캐싱 적용 중 생긴 버그 해결
iftype Apr 9, 2026
4606ee1
feat: Repository 레이어 도입
iftype Apr 10, 2026
ddbc854
fix: console log 삭제
iftype Apr 10, 2026
a5f0828
style: 모달 레이아웃 변경
iftype Apr 10, 2026
d9f25ba
feat: modal 키다운 이벤트 추가
iftype Apr 10, 2026
1c0f52c
fix: console log 삭제
iftype Apr 10, 2026
1a298a0
feat: 레포지토리 비동기로 매핑
iftype Apr 10, 2026
07401a9
chore: 주석 제거 및 중복 스타일 제거
iftype Apr 10, 2026
9ea392a
fix: 타이핑 오류 수정
iftype Apr 10, 2026
32a09d4
refactor: Error 컴포넌트 위치 변경
iftype Apr 10, 2026
cd7cda0
test: 모달과 별점 테스트 추가
iftype Apr 10, 2026
34f4bec
fix: 스크롤 내린 후 새로고침 문제 해결
iftype Apr 11, 2026
8600412
chore: window 객체 명시
iftype Apr 12, 2026
473fb51
refactor: 불필요한 Repo 레이어 제거
iftype Apr 12, 2026
05c9606
refactor: IntersectionObserver 도입 및 중복 로직 삭제
iftype Apr 12, 2026
80c0f9b
refactor: 모달의 이미지 스켈레톤 도입
iftype Apr 12, 2026
f7b6367
refactor: HomePage, SearchPage 통합
iftype Apr 13, 2026
be28652
refactor: Modal 생성 위치 변경
iftype Apr 13, 2026
39db170
refactor: TopRateHeader와 SearchHeader 통합
iftype Apr 13, 2026
332e839
test: 로컬 스토리지가 아닌 실제 요소를 보도록 수정
iftype Apr 13, 2026
5ea837e
fix: 모달 스켈레톤 에러 수정
iftype Apr 13, 2026
03970e1
feat: 헤더의 버튼에 디테일 이벤트 추가
iftype Apr 13, 2026
532d0b4
refactor: 엔트리포인트 부터 의존성 주입
iftype 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 __test__/api.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { fetchPopularMovies, fetchSearchMovies } from '../src/api/fetchApi.ts';
import MOCK_DATA from './mock/page_1.json';
import MOCK_ERROR from './mock/page_error.json';

vi.stubGlobal('fetch', vi.fn());

Expand Down
87 changes: 87 additions & 0 deletions __test__/mock/item_1.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
{
"adult": false,
"backdrop_path": "/1x9e0qWonw634NhIsRdvnneeqvN.jpg",
"belongs_to_collection": null,
"budget": 0,
"genres": [
{
"id": 10749,
"name": "로맨스"
},
{
"id": 18,
"name": "드라마"
}
],
"homepage": "",
"id": 1523145,
"imdb_id": "tt38190257",
"origin_country": [
"RU"
],
"original_language": "ru",
"original_title": "Твоё сердце будет разбито",
"overview": "",
"popularity": 821.6874,
"poster_path": "/iGpMm603GUKH2SiXB2S5m4sZ17t.jpg",
"production_companies": [
{
"id": 71928,
"logo_path": "/7M6z2jX570P3HzNQULCTjlZcXkB.png",
"name": "All Media A Start Company",
"origin_country": "RU"
},
{
"id": 114676,
"logo_path": "/aVsos0RDN9Ib7H8nW5Vv5kHKykz.png",
"name": "START Studio",
"origin_country": "RU"
},
{
"id": 13850,
"logo_path": "/6KsdcnTIpQ47NHad4EtxOCkB94K.png",
"name": "Sverdlovsk Film Studio",
"origin_country": "RU"
},
{
"id": 76049,
"logo_path": "/tam9pIV4QK1ZPwdxBgtVkK1WTYC.png",
"name": "Cinema Foundation of Russia",
"origin_country": "RU"
},
{
"id": 42877,
"logo_path": "/15THT1W3NROsbDXPbrcmWFVkrSv.png",
"name": "Yellow, Black & White",
"origin_country": "RU"
},
{
"id": 119813,
"logo_path": "/iyGJzcnWXZoNxYLzZu4jKzxIoFw.png",
"name": "Premier Studios",
"origin_country": "RU"
}
],
"production_countries": [
{
"iso_3166_1": "RU",
"name": "Russia"
}
],
"release_date": "2026-03-26",
"revenue": 0,
"runtime": 134,
"spoken_languages": [
{
"english_name": "Spanish",
"iso_639_1": "es",
"name": "Español"
}
],
"status": "Released",
"tagline": "",
"title": "Твоё сердце будет разбито",
"video": false,
"vote_average": 7.045,
"vote_count": 56
}
93 changes: 67 additions & 26 deletions cypress/e2e/spec.cy.ts
Original file line number Diff line number Diff line change
@@ -1,40 +1,85 @@
import MOCK_PAGE_1 from '../../__test__/mock/page_1.json';
import MOCK_PAGE_2 from '../../__test__/mock/page_2.json';
import MOCK_ITEM_1 from '../../__test__/mock/item_1.json';
import MOCK_ERROR from '../../__test__/mock/page_error.json';

describe('영화 리뷰 앱 E2E 테스트', () => {
describe('e2e 테스트', () => {
beforeEach(() => {
cy.intercept('GET', '**/movie/popular?*page=1*', {
statusCode: 200,
body: MOCK_PAGE_1,
}).as('getPopularMovies');
}).as('getPopularMoviePage1');

cy.intercept('GET', '**/movie/popular?*page=2*', {
statusCode: 200,
body: MOCK_PAGE_2,
}).as('getNextPopularMovies');
}).as('getPopularMoviePage2');

cy.intercept('GET', '**/movie/[0-9]*', {
statusCode: 200,
body: MOCK_ITEM_1,
}).as('getMovieDetail');
});
Comment on lines 7 to 22
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

fetchMovieDetails API는 mock하지 않고 있는데, 실제 API를 호출하는 건 의도일까요?

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.

아뇨 전혀 의도가 아니었습니다.
테스트 짜는 과정 중 누락되었습니다.. 해당 부분 수정하겠습니다


context('1. 메인 페이지 (인기 영화)', () => {
it('메인 페이지에 접속하면 인기 영화 20개를 렌더링한다.', () => {
cy.visit('http://localhost:5175/');
cy.wait('@getPopularMovies');
context('사용자는 접속하여 홈페이지에서 영화 리스트를 볼 수 있다', () => {
it('메인 페이지의 소개 문구 확인', () => {
cy.visit('http://localhost:5173/');
cy.wait('@getPopularMoviePage1');

cy.get('.top-rated-movie .title').should('have.text', MOCK_PAGE_1.results[0].title);
cy.get('section h2').should('have.text', '지금 인기있는 영화');
cy.get('.thumbnail-list > li').should('have.length', 20);
});

it('더보기 버튼을 누르면 다음 페이지의 영화 20개를 추가로 렌더링한다.', () => {
cy.visit('http://localhost:5175/');
cy.wait('@getPopularMovies');
it('스크롤을 내리면 다음 페이지의 영화 20개를 추가로 렌더링한다.', () => {
cy.visit('http://localhost:5173/');
cy.wait('@getPopularMoviePage1');

cy.get('.more-button').click();
cy.wait('@getNextPopularMovies');
cy.scrollTo('bottom');
cy.wait('@getPopularMoviePage2');

cy.get('.thumbnail-list > li').should('have.length', 40);
Comment on lines +34 to 41
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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Check if there are any scroll-related test utilities or configurations

# Search for any existing scroll test helpers or wait configurations
rg -n "scroll" --type=ts -C2 cypress/

Repository: woowacourse/javascript-movie-review

Length of output: 315


🏁 Script executed:

# Search for scroll event handlers and custom event implementation
rg -n "SCROOL_END|scroll.*Event|scrollTo" --type=ts -C3 src/

Repository: woowacourse/javascript-movie-review

Length of output: 2455


🏁 Script executed:

# Check the event utilities mentioned in the comment
cat -n src/utils/event.ts | head -50

Repository: woowacourse/javascript-movie-review

Length of output: 873


🏁 Script executed:

# Look for API interceptor setup to understand `@getNextPopularMovies`
rg -n "getNextPopularMovies|@getPopularMovies|cy\.intercept" --type=ts -C2 cypress/

Repository: woowacourse/javascript-movie-review

Length of output: 2431


스크롤 기반 테스트의 구조는 적절합니다.

cy.scrollTo('bottom')은 네이티브 스크롤 이벤트를 발생시키고, src/main.ts의 스크롤 리스너가 이를 감지하여 scrollEvent()를 호출합니다. src/utils/event.ts에서 300ms 쓰로틀을 거쳐 CUSTOM_EVENT.SCROOL_END를 디스패치하고, 페이지가 이 이벤트를 수신하여 API 요청을 트리거합니다. cy.wait('@getNextPopularMovies')는 기본값으로 5초 동안 대기하므로, 300ms 쓰로틀 딜레이는 충분히 처리됩니다.

현재 테스트 구조는 단일 스크롤 액션이므로 타이밍 문제 없이 작동해야 합니다. 다만, CI 환경에서 실제로 안정적으로 동작하는지 여러 번 실행해 확인하면 좋습니다.

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

In `@cypress/e2e/spec.cy.ts` around lines 26 - 33, The test can flake on CI due to
the 300ms throttle in src/utils/event.ts and network variability; update the
test that calls cy.scrollTo('bottom') to wait longer for the API alias by
changing cy.wait('@getNextPopularMovies') to cy.wait('@getNextPopularMovies', {
timeout: 10000 }) (or another increased timeout) so the
scrollEvent()/CUSTOM_EVENT.SCROOL_END debounce in src/main.ts has ample time to
trigger the second fetch after the initial cy.wait('@getPopularMovies'); keep
the same aliases and assertions ('.thumbnail-list > li' length check) but
increase the wait timeout to stabilize CI runs.

});

it('영화 리스트 중 하나 클릭했다가 취소하고 나온다 ESC도 가능해야한다', () => {
cy.visit('http://localhost:5173/');
cy.wait('@getPopularMoviePage1');

cy.get('ul li:first-child').click();
cy.get('.modal').should('be.visible');

cy.get('.close-modal').click();
cy.get('.modal').should('not.be.visible');

cy.get('body').type('{esc}');
cy.get('.modal').should('not.be.visible');
});
});

context('2. 검색 기능', () => {
context('모달', () => {
it('사용자는 모달을 열고 평점을 6점준다. 별점은 로컬 스토리지에 저장된다', () => {
cy.visit('http://localhost:5173/');
Comment on lines +60 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.

localStorage를 직접 확인하는 것보다, 새로고침 후 별점이 유지되는지를 사용자 관점에서 확인하는 시나리오가 더 E2E 테스트의 역할에 맞지 않을까요?

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.

e2e에 대한 이해도가 부족했었네요. 사용자 관점에서 처음부터 끝까지란걸 놓쳤습니다.
해당 부분 수정하겠습니다!

cy.wait('@getPopularMoviePage1');

cy.get('ul li:first-child').click();
cy.get('.modal').should('be.visible');

cy.get('.modal-description h2').should('contain', MOCK_ITEM_1.title);
cy.get('.rate span').should('contain', MOCK_ITEM_1.vote_average.toFixed(1));
cy.get('.star-container .submit-star-button').eq(2).click();

cy.get('.star-container .submit-star-button').each(($el, index) => {
const img = $el.find('img');
if (index <= 2) {
cy.wrap(img).should('have.attr', 'alt', 'star_filled');
} else {
cy.wrap(img).should('have.attr', 'alt', 'star_empty');
}
});
});
});

context('검색 기능', () => {
it('검색창에 검색어를 입력하고 엔터를 누르면 검색 결과 페이지로 이동한다.', () => {
const encodedQuery = encodeURIComponent('해리포터');

Expand All @@ -43,49 +88,45 @@ describe('영화 리뷰 앱 E2E 테스트', () => {
body: MOCK_PAGE_1,
}).as('getSearchMovies');

cy.visit('http://localhost:5175/');
cy.visit('http://localhost:5173/');

cy.get('input[name="q"]').type('해리포터{enter}');
cy.wait('@getSearchMovies');

cy.url().should('include', `/search?query=${encodedQuery}`);
cy.get('.thumbnail-list > li').should('have.length', 20);
cy.get('.thumbnail-list li').should('have.length', 20);
});

it('검색 결과가 없는 경우 "검색 결과가 없습니다" UI를 띄워준다.', () => {
const emptyMockData = { page: 1, results: [], total_pages: 0 };

const encodedEmptyQuery = encodeURIComponent('ㅇㅅㅇ');
const encodedEmptyQuery = encodeURIComponent('궤뚫쉟헽');

cy.intercept('GET', `**/search/movie?*query=${encodedEmptyQuery}*`, {
statusCode: 200,
body: emptyMockData,
}).as('getEmptyMovies');

cy.visit('http://localhost:5175/');
cy.get('input[name="q"]').type('ㅇㅅㅇ{enter}');
cy.visit('http://localhost:5173/');
cy.get('input[name="q"]').type('궤뚫쉟헽{enter}');
cy.wait('@getEmptyMovies');

cy.get('.nothing').should('be.visible');
cy.get('.nothing p').contains('검색 결과가 없습니다.');
});
});

context('3. 예외 처리 (API 에러)', () => {
it('네트워크 통신에 실패할 경우 에러 메시지를 화면에 표시한다.', () => {
cy.on('uncaught:exception', (err, runnable) => {
return false;
});

context('API 에러', () => {
it('에러가 발생 시 에러 컴포넌트를 불러옴', () => {
cy.intercept('GET', '**/movie/popular?*', {
statusCode: 500,
body: MOCK_ERROR,
}).as('getApiError');

cy.visit('http://localhost:5175/');
cy.visit('http://localhost:5173/');
cy.wait('@getApiError');
cy.get('.nothing').should('be.visible');
cy.get('.nothing p').should('contain', 'TMDB');
cy.get('.nothing p').should('contain', '에러');
});
});
});
1 change: 1 addition & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
<link rel="stylesheet" href="./styles/main.css" />
<link rel="stylesheet" href="./styles/tab.css" />
<link rel="stylesheet" href="./styles/thumbnail.css" />
<link rel="stylesheet" href="./styles/modal.css" />
</head>

<body>
Expand Down
1 change: 1 addition & 0 deletions src/api/constant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ export const URL = {
export const PATH = {
MOVIE_POPULAR: '/movie/popular',
SEARCH_MOVIE: '/search/movie',
MOVIE_DETAIL: (movie_id: number) => `/movie/${movie_id}`,
} as const;
16 changes: 11 additions & 5 deletions src/api/fetchApi.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { URL, PATH } from './constant.ts';
import TMDBError from './TMDBError.ts';
import { ResponseMovie, Request, TmdbErrorType } from './types.ts';
import { ResponseMovie, Request, TmdbErrorType, MovieDetail } from './types.ts';

const API_KEY = import.meta.env.VITE_API_KEY;
const options = {
Expand All @@ -11,15 +11,14 @@ const options = {
},
};

const fetchAPI = async (req: Request): Promise<ResponseMovie> => {
const fetchAPI = async <T extends ResponseMovie | MovieDetail>(req: Request): Promise<T> => {
const url = URL.BASE + req.path;

const { query, page } = req.params;
const params = new URLSearchParams({ language: 'ko-KR', page: String(page), region: 'kr' });
const params = new URLSearchParams({ language: 'ko-KR', region: 'kr' });
if (query) params.set('query', query);

if (page) params.set('page', String(page));
const resultUrl = url + '?' + params.toString();

const response = await fetch(resultUrl, options);
const data = await response.json();

Expand All @@ -43,3 +42,10 @@ export const fetchPopularMovies = (page: number = 1): Promise<ResponseMovie> =>
params: { page },
});
};

export const fetchMovieDetails = (movie_id: number): Promise<MovieDetail> => {
return fetchAPI({
path: PATH.MOVIE_DETAIL(movie_id),
params: {},
});
};
5 changes: 4 additions & 1 deletion src/api/renderImage.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { URL } from './constant';

export const getOriginalImageUrl = (src: string): string => {
const PLACEHOLDER = './images/empty.png';
export const getOriginalImageUrl = (src: string | null): string => {
if (!src) return PLACEHOLDER;
return URL.ORIGINAL_IMAGE + src;
};

export const getThumbnailImageUrl = (src: string): string => {
if (!src) return PLACEHOLDER;
return URL.THUMBNAIL_IMAGE + src;
};
16 changes: 15 additions & 1 deletion src/api/types.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,32 @@
export type MovieData = {
id: number;
title: string;
poster_path: string;
backdrop_path: string;
vote_average: number;
};

type Genres = {
name: string;
};
export type MovieDetail = {
id: number;
title: string;
overview: string;
poster_path: string;
genres: Genres[];
vote_average: string;
release_date: string;
};
Comment on lines +12 to +20
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

🧩 Analysis chain

🌐 Web query:

TMDB API movie details vote_average type

💡 Result:

In TMDB’s Movie Details response (GET /3/movie/{movie_id}), vote_average is a JSON number (i.e., a float/double), representing the movie’s average user rating. The value may be returned with more than 1 decimal place (TMDB has discussed it showing up to ~3 decimals at times). [1][2]

Sources:
[1] (gist.github.com)
[2] (themoviedb.org)

Citations:


MovieDetail.vote_average 타입 수정 필요

TMDB API 검증 결과에 따르면 vote_average는 **숫자 타입(JSON number)**으로 반환됩니다.

현재 코드에서:

  • MovieData.vote_averagenumber로 올바르게 정의됨
  • MovieDetail.vote_averagestring으로 정의됨

두 타입이 불일치합니다.

다음 질문을 고려하며 해결 방법을 생각해 보세요:

  • TMDB API가 실제로 반환하는 타입은 무엇인가요?
  • 두 타입 정의가 같은 데이터 소스를 다루고 있다면, 왜 다르게 정의되어 있을까요?
  • 타입이 일치하지 않으면 데이터 처리 시 어떤 문제가 발생할 수 있을까요?
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/api/types.ts` around lines 12 - 20, MovieDetail.vote_average is
incorrectly typed as string while TMDB returns a JSON number and
MovieData.vote_average is already a number; update the MovieDetail type to use
number for vote_average to match the API and MovieData, then search for usages
of MovieDetail.vote_average (e.g., any code that assumes a string) and update
parsing/formatting (toFixed, toString, etc.) as needed to handle numeric values
consistently.


export type ResponseMovie = {
results: MovieData[];
page: number;
total_pages: number;
};

type Params = {
page: number;
page?: number;
query?: string | undefined;
language?: string;
region?: string;
Expand Down
13 changes: 13 additions & 0 deletions src/components/common/ErrorComponent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import TMDBError from '../../api/TMDBError';

export const ErrorComponent = (error: Error | TMDBError) => {
const $div = document.createElement('div');
const infoMessage = error instanceof TMDBError ? 'TMDB 에러' : '예상치못한 에러';
$div.className = 'nothing';
$div.innerHTML = `
<img src="./images/empty.png" alt="nothing" />
<p>${infoMessage}</p>
`;

return $div;
};
14 changes: 14 additions & 0 deletions src/components/common/Rate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Star } from './Star';

export const Rate = (vote_average: string, filled?: boolean) => {
const $p = document.createElement('p');
$p.className = 'rate';

const $span = document.createElement('span');

const roundVote = Math.round(Number(vote_average) * 10) / 10;
$span.textContent = roundVote.toFixed(1);

$p.append(Star(filled), $span);
return $p;
};
6 changes: 3 additions & 3 deletions src/components/common/Star.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
export const Star = () => {
export const Star = (filled?: boolean) => {
const $img = document.createElement('img');
$img.className = 'star';
$img.src = './images/star_empty.png';
$img.alt = 'star_empty';
$img.src = filled ? './images/star_filled.png' : './images/star_empty.png';
$img.alt = filled ? 'star_filled' : 'star_empty';

return $img;
};
Loading