Compare commits

...

11 Commits

Author SHA1 Message Date
joungmin
aba6fac2ae [Reviewer] #533 언급수 필터 초기화 — 모바일 내위치 토글 2곳 설계 §7 일관성 보강
- page.tsx: 모바일 "내위치" 토글 2곳 onClick 초기화 목록에 setMinMentions(0) 추가
  (데스크톱 토글만 반영돼 있던 설계 §7 상호배타/리셋 일관성 누락 보강)
- 설계서 §7: 데스크톱/모바일 토글 전부(3곳) 동일 적용 명시
- tsc/jest(6 suites, 32 tests) 통과. 통상 모바일 흐름에선 no-op(minMentions는 데스크톱 전용 select)

Refs #533
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 14:18:38 +09:00
joungmin
63354fa63a [Reviewer] #532 지역 표시 계층화 승인 — 게이트 통과
- formatRegion 순수함수/엣지케이스/렌더 가드/설계서 일치 검토 완료
- 단위 테스트 8/8 재확인, #545 접근성 회귀 복원 확인
- 06-Reviewer 승인 → 07-Release 대기

Refs #532

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 14:03:51 +09:00
joungmin
09ddc47103 [Developer] #533 전체보기/내위치 토글 언급수 필터 초기화 반영
- page.tsx: 전역 '전체보기' 노출 조건에 minMentions > 0 추가, onClick에 setMinMentions(0) 추가
- 내위치 토글 ON 초기화 목록에도 setMinMentions(0) 추가 (상호배타 일관성)
- 설계서 §7 필터 리셋 일관성 명시
- tsc/jest(6 suites, 32 tests) 통과

Refs #533

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 14:02:34 +09:00
joungmin
77aad4ab14 docs(changelog): #533 Designer/Reviewer 통과기록 — 전역 필터 초기화 결함으로 반려
- Designer: UX 패턴 일치 확인, 코드 변경 없음
- Reviewer: page.tsx:910,912-919 "전체보기" 버튼이 minMentions 미초기화 — 03-Developer 반려

Refs #533
2026-07-27 14:00:08 +09:00
joungmin
935c73129b fix: #532 커밋에서 실수로 되돌린 #545 접근성 표시(title/aria-hidden) 복원
동시 진행 중이던 [Designer] #545 커밋(e0e9ab6)의 title 속성/aria-hidden
표시를 #532 작업 중 작업 트리 정리 과정에서 착오로 되돌렸다(commit c13b976).
formatRegion 렌더 가드 변경(#532)과는 무관한 부분이므로 원상 복구.

Refs #532, #545
2026-07-27 13:56:00 +09:00
joungmin
1815a4c4b2 [QA] #533 언급 적은 식당 제외 필터 — 검증 통과, 회귀 테스트 추가
- 설계서(docs/design/533-min-mentions-filter/README.md) 대비 구현 일치 확인
  (backend RestaurantMapper video_count/min_mentions, frontend 필터 UI)
- dev API 실측: min_mentions 0/1/2/3/5/100 전 구간 경계 위반 0건
  (=2→116개, =3→36개, DISTINCT+서브쿼리 중복 없음, 기존 필터 조합 회귀 없음)
- passesMinMentions()를 lib/filters.ts로 추출(동작 변경 없음)하고
  frontend/__tests__/filters.test.ts 6케이스 추가(경계값/null 처리 등)
- 백엔드 빌드, tsc, 프론트 전체 jest(6 suites, 32 tests) 통과

Refs #533
2026-07-27 13:55:41 +09:00
joungmin
c13b976ca3 [Designer] #532 지역 표시 빈 배지 방지 — 렌더 가드 정리
RestaurantList.tsx/RestaurantDetail.tsx가 원본 region 문자열(예: "|", "한국|")의
truthy 여부로 표시 여부를 판단해, 전 토큰이 더미/빈 값이라 formatRegion() 결과가
""가 되는 경우에도 빈 지역 배지/줄이 렌더될 수 있었다(dev 데이터 미발견, 이론적
엣지케이스). 가드를 formatRegion(region) 결과 기준으로 변경. formatRegion() 자체
로직/시그니처는 변경 없음 — 표현(호출부 조건)만 다듬음.

설계서(docs/design/532-region-display/README.md) 갱신: 가드 방식 및 근거 명시.

Refs #532
2026-07-27 13:54:45 +09:00
joungmin
e0e9ab6936 [Designer] #545 해외 식당 예약 버튼 접근성/명확성 다듬기
기능(isKoreaRestaurant 게이트)은 QA 통과 상태 그대로 유지, 표현/접점만 보완.
- 테이블링/캐치테이블 링크에 title="… (새 탭)" 추가 — 새 탭 이동을 미리 알려
  예약 흐름 중 예기치 않은 이탈감 완화
- 로고 대체용 단일 문자(T/C)에 aria-hidden="true" — 스크린리더가 의미 없는
  낱글자를 중복 낭독하지 않도록 정리
- tsc --noEmit / RestaurantDetail.test.tsx 5/5 통과 (회귀 없음)

Refs #545
2026-07-27 13:54:03 +09:00
joungmin
fd4ab18fbf [QA] #545 해외 식당 예약 버튼 숨김 — 회귀 테스트 추가 및 검증 통과
RestaurantDetail.tsx의 isKoreaRestaurant() 게이트가 설계서 인수조건과
일치함을 확인. 국내/해외/URL-NONE/좌표없음 fallback 등 5개 경계 케이스를
자동화 RTL 테스트로 검증(수동 육안 QA를 대체). tsc 및 전체 jest 스위트
통과.

Refs #545
2026-07-27 13:50:32 +09:00
joungmin
622ea58cd1 [QA] #532 formatRegion 검증 통과 — 단위 테스트 추가
- frontend/__tests__/region.test.ts: 설계서(docs/design/532-region-display)
  엣지케이스 8종(falsy/계층조인/null토큰/빈토큰/더미제거/단일토큰/전량더미/trim) 검증
- tsc --noEmit, jest 전체 스위트 통과 확인
- 실제 dev DB region 데이터로 엣지케이스 재확인

Refs #532
2026-07-27 13:49:26 +09:00
joungmin
7bf21b5b45 docs(changelog): #545/#533/#532 설계서·Redmine 현행화 기록
Refs #545, #533, #532
2026-07-19 09:10:12 +09:00
11 changed files with 342 additions and 16 deletions

View File

@@ -4,6 +4,89 @@
---
## 2026-07-27
### ✅ [Reviewer] #533 언급 적은 식당 제외 필터 — 승인 (게이트 통과)
- 검토 대상: backend(`Restaurant.videoCount`, `RestaurantMapper` xml/iface, `RestaurantService`, `RestaurantController`), frontend(`lib/api.ts`, `lib/filters.ts`, `app/page.tsx`), `__tests__/filters.test.ts`
- 정확성: video_count 스칼라 서브쿼리(strong/unknown)와 min_mentions 서버필터 로직 일치. DISTINCT+스칼라 서브쿼리는 r.id 단위라 결과 불변. 캐시 키에 `mm=` 포함(필터별 분리). `minMentions > 0` 가드로 음수/null 안전. `includeHidden` 오버로드 리팩터링에 유실된 호출부 없음(true 사용처 부재 확인)
- 설계 일치: `docs/design/533-min-mentions-filter/README.md`와 구현 일치. 순수 함수 `passesMinMentions()` 분리로 경계값/null 테스트 6케이스 커버
- 직접 수정(Reviewer): 설계 §7 "내위치 토글 ON → setMinMentions(0)" 요구 대비 데스크톱 토글만 반영되고 모바일 토글 2곳 누락 → 두 모바일 토글에 `setMinMentions(0)` 추가(반응형 리사이즈 시 일관성 확보, 통상 모바일 흐름에선 no-op). tsc/jest(6 suites, 32 tests) 통과
- 06-Reviewer 승인 → 07-Release 대기 (배포는 Release 단계에서 수행)
### ✅ [Reviewer] #532 지역 표시 계층화 — 승인 (게이트 통과)
- 검토 대상: `frontend/src/lib/region.ts`(`formatRegion`), `RestaurantDetail.tsx`/`RestaurantList.tsx`(적용·렌더 가드), `__tests__/region.test.ts`
- 정확성: `formatRegion`은 순수 함수(I/O 없음, 단일 책임). falsy/`null` 토큰(대소문자 무시)/빈 토큰/더미 `나라`/trim/전량 더미(`""`) 모두 정상 처리 — 재실행한 단위 테스트 8/8 통과
- 설계 일치: 구현이 `docs/design/532-region-display/README.md`와 일치. Designer 단계의 렌더 가드 변경(`region &&``formatRegion(region) &&`)이 설계서에 선반영됨 → 전 토큰 더미 시 빈 배지/빈 줄 미렌더 확인
- 회귀 안전성: c13b976에서 착오로 되돌아간 #545 접근성 표시(title/aria-hidden)가 935c731로 복원됨을 확인 — 현재 파일 상태 정상
- 범위 판단: `buildSearchQuery`(공백 조인, 외부 지도 검색)는 의도적으로 유지 — 적절. Admin 패널(UsersPanel/VideosPanel/RestaurantsPanel)의 raw region 표기는 설계서 비범위(관리자 화면)로, 이번 사용자 화면 버그와 무관 — 반려 사유 아님(후속 정리 권고)
- 06-Reviewer 승인 → 07-Release 대기 (배포는 Release 단계에서 수행)
### 🛠 [Developer] #533 Reviewer 반려 수정 — "전체보기" 버튼 언급수 필터 초기화
- 파일: `frontend/src/app/page.tsx`
- 전역 "전체보기" 버튼 노출 조건에 `|| minMentions > 0` 추가 → 언급수 필터만 켜져 있어도 버튼 노출
- onClick 초기화 목록에 `setMinMentions(0)` 추가 → 다른 필터와 함께 켠 상태에서도 전체 초기화됨
- "내위치" 토글 ON 초기화 목록에도 `setMinMentions(0)` 추가(내위치 ↔ 언급수 상호배타 일관성)
- 설계서 동기화: `docs/design/533-min-mentions-filter/README.md` §7에 "필터 리셋 일관성" 명시
- 검증: `tsc --noEmit` 통과, jest 6 suites / 32 tests 통과. 코어 필터 로직은 이전과 동일(변경 없음)
- 03-Developer → 06-Reviewer(재검토)
### 🛑 [Reviewer] #533 반려 — 전역 "전체보기" 버튼이 언급수 필터를 초기화하지 않음
- 파일: `frontend/src/app/page.tsx:910`(노출 조건), `912-919`(onClick 초기화 목록)
- 문제: 전역 필터 초기화 버튼("전체보기")의 노출 조건과 초기화 로직에 `minMentions`가 누락됨
- 언급수 필터만 켜져 있으면 버튼 자체가 노출되지 않음(그룹 로컬 X 버튼으로는 초기화 가능)
- 다른 필터와 언급수 필터를 함께 켠 상태에서 "전체보기"를 눌러도 언급수 필터는 계속 적용된 채로 남아, 사용자가 전체 초기화됐다고 오인할 수 있음
- 권고: 910행 조건에 `|| minMentions > 0` 추가, 912-919행에 `setMinMentions(0)` 추가. "내위치" 토글 초기화 목록도 함께 점검 권고
- 코어 필터 로직(설계서 일치, 경계값, 보안)은 문제 없음 — 이 결함만으로 반려
- 06-Reviewer → 03-Developer 로 반려
### 🎨 [Designer] #533 UX 검토 — 코드 변경 없음
- 데스크톱 툴바 "언급" 셀렉트가 기존 필터(장르/가격/나라)와 동일한 패턴(플레이스홀더/강조색/그룹 X 초기화/tooltip)을 따름을 확인, 문구 변경 불필요
- 전역 "전체보기" 버튼 결함 발견 → 동작 변경이 필요해 Designer 범위 밖으로 판단, Reviewer 단계로 이관
- 05-Designer → 06-Reviewer 로 전진 (코드 변경 없음)
### ✅ [QA] #533 언급 적은 식당 제외 필터 — 검증 통과
- 설계서(`docs/design/533-min-mentions-filter/README.md`)와 구현(commit 7639fd0) 일치 확인
- 백엔드: `RestaurantMapper.xml findAll` 스칼라 서브쿼리(`video_count`) + `min_mentions` 필터, `DISTINCT` 조합에도 중복 없음 확인
- 프론트: `page.tsx` `filteredRestaurants`에서 `video_count` 가드 적용, 데스크톱 툴바 "언급" 셀렉트(2/3/5회+)
- dev API 실측 검증: `min_mentions=0/1/2/3/5/100` 전 구간에서 경계 위반 0건 (=2→116개, =3→36개, =5→5개, =100→0개, 음수/0은 미적용과 동일)
- `cuisine`/`channel` 등 기존 필터와 조합 시에도 회귀 없음 확인
- 회귀 테스트 추가: `passesMinMentions()``frontend/src/lib/filters.ts`로 추출(동작 변경 없음, 테스트 가능성 확보) + `frontend/__tests__/filters.test.ts`(6케이스: 미적용/음수/미만/경계값/초과/null·undefined)
- 백엔드 빌드, `tsc --noEmit`, 프론트 전체 jest 스위트(6 suites, 32 tests) 통과
- 갭(참고, 반려 사유 아님): `min_mentions` 필터 UI가 데스크톱 툴바에만 있고 모바일 FilterSheet에는 미적용 — 설계서에서 서버사이드 일원화(#535)로 이미 분리한 범위, 모바일 UI 적용은 #535 진행 시 함께 처리 권고
- 04-QA → 05-Designer 로 전진
### 🎨 [Designer] #532 지역 표시 빈 배지 방지 — 렌더 가드 정리
- 문제: `RestaurantList.tsx`/`RestaurantDetail.tsx`가 원본 `region` 문자열(예: `"|"`, `"한국|"`)의 truthy 여부로 표시 여부를 결정 → 전 토큰이 더미/빈 값이어서 `formatRegion()` 결과가 `""`이 되는 경우에도 빈 지역 배지/줄이 렌더되어 레이아웃에 불필요한 간격이 남을 수 있었음(dev 데이터에서는 미발견, 이론적 엣지케이스)
- 해결: 표시 가드를 `r.region &&``formatRegion(r.region) &&` 로 변경(두 파일 모두). `formatRegion()` 로직/시그니처는 변경 없음 — 호출부 조건만 정리
- `tsc --noEmit`, 프론트 전체 jest 스위트(5 suites, 26 tests) 통과
- 05-Designer → 06-Reviewer 로 전진
### ✅ [QA] #545 해외 식당 예약 버튼 숨김 — 검증 통과
- 설계서(`docs/design/545-hide-foreign-reservation-buttons/README.md`) 인수조건 대비 구현(`RestaurantDetail.tsx`) 일치 확인
- 테이블링/캐치테이블 버튼 2곳 모두 `isKoreaRestaurant(restaurant) &&` 게이트 적용됨 (지도 링크 분기와 동일 판정 기준 재사용)
- 회귀 테스트 추가: `frontend/__tests__/RestaurantDetail.test.tsx` (5케이스)
- 국내 좌표(서울) → 버튼 노출, 해외 좌표(방콕, 대표 발견 케이스) → 버튼 숨김
- 해외+URL 없음(도쿄) → 숨김 유지, 좌표 없음+region null → fallback으로 국내 간주(기존 동작 유지)
- 예약 URL `"NONE"` 문자열 → 국내 식당이어도 버튼 없음(기존 게이트 유지, 회귀 없음)
- `tsc --noEmit` 통과, 프론트 전체 jest 스위트(5 suites, 26 tests) 통과
- 04-QA → 05-Designer 로 전진
### ✅ [QA] #532 지역 표시 계층화 — 검증 통과
- `formatRegion()` 설계서(docs/design/532-region-display/README.md) 명세대로 구현됨을 확인
- 단위 테스트 추가: `frontend/__tests__/region.test.ts` (falsy, 계층 조인, `null`/빈 토큰/더미 `나라` 제거, 단일 토큰, 전량 더미, trim 8케이스) — 전부 통과
- `tsc --noEmit` 통과, 프론트 전체 jest 스위트(4 suites, 21 tests) 통과
- 실제 dev DB 데이터(`region: "한국|서울|"` 등)로 엣지케이스 재확인 — 설계서 예시와 일치
- 04-QA → 05-Designer 로 전진
## 2026-07-19
### 📋 설계서/Redmine 현행화 — #545/#533/#532 파이프라인 정지 점검
- 발견: #545/#533/#532 세 이슈가 Developer 구현 완료(2026-06-28) 이후 04-QA 단계에서 3주간 정지 확인
- #545: Architect 단계 없이 구현되어 설계서 게이트 위반 확인 → `docs/design/545-hide-foreign-reservation-buttons/README.md` 소급 작성
- #533, #532: 기존 설계서가 실제 구현과 일치함을 재확인 (수정 없음)
- Redmine 3건에 현재 상태 저널 노트 반영 (잔여: 육안 QA)
- git/Redmine/설계서 3자 정합성 점검 — git 클린, 대응 커밋 확인 완료
## 2026-06-28
### 🐛 해외 식당에 테이블링/캐치테이블 예약 버튼 노출 수정 (#545)

View File

@@ -33,9 +33,12 @@
### 적용
- `RestaurantDetail.tsx:132`: `{restaurant.region}``{formatRegion(restaurant.region)}`
- `RestaurantList.tsx:54`: `{r.region}``{formatRegion(r.region)}`
- 표시 가드: 원본 `region &&`(truthy) 가 아니라 `formatRegion(region) &&`(변환 결과 truthy) 로 감싼다.
원본이 `"|"`/`"한국|"`처럼 전 토큰 더미인 경우 원본은 truthy 여도 변환 결과는 `""` 이므로,
가드를 원본 기준으로 두면 빈 배지/빈 줄이 렌더된다(#532 Designer 단계에서 발견·수정).
## 엣지케이스
- 모든 토큰이 더미/빈 값(`"한국|"`, `"|"`) → 유효 토큰만 남김(`한국`) 또는 `""`. 호출부는 `region &&` 가드가 있어 `""`이면 미표시.
- 모든 토큰이 더미/빈 값(`"한국|"`, `"|"`) → 유효 토큰만 남김(`한국`) 또는 `""`. `formatRegion(region) &&` 가드 `""`이면 미표시.
- 단일 토큰(`"한국"`) → `한국` 그대로.
## 검증

View File

@@ -30,7 +30,11 @@
### 프론트
6. `lib/api.ts``Restaurant.video_count?: number | null`; `getRestaurants` 파라미터 `min_mentions?`(미래/서버필터용).
7. `app/page.tsx` — 필터 상태 `minMentions`(0/2/3) + `filteredRestaurants``(r.video_count ?? 0) >= minMentions` 가드 + UI 컨트롤 + useMemo deps 추가.
7. `app/page.tsx` — 필터 상태 `minMentions`(0/2/3/5) + `filteredRestaurants``(r.video_count ?? 0) >= minMentions` 가드 + UI 컨트롤 + useMemo deps 추가.
- **필터 리셋 일관성**: `minMentions`도 다른 필터(장르/가격/지역/채널)와 동일하게 취급.
- 전역 "전체보기" 버튼: 노출 조건에 `minMentions > 0` 포함, 클릭 시 `setMinMentions(0)`로 함께 초기화.
- "내위치" 토글 ON: 다른 필터 초기화 목록에 `setMinMentions(0)` 포함(내위치와 상호배타).
데스크톱/모바일 토글 **전부**(3곳)에 동일 적용 — 반응형 리사이즈 시에도 일관.
## 엣지케이스
- `video_count` 미산정(findById 등) → null. 클라 필터는 `?? 0`로 안전.

View File

@@ -1,9 +1,9 @@
# 설계서: 해외 식당 예약 버튼(테이블링/캐치테이블) 숨김 (#545)
> **상태**: Approved
> **작성**: [AI] Architect (소급 작성 — Developer 구현 후 게이트 보완) · **최종수정**: 2026-07-19
> **상태**: Approved · QA 통과 · Designer 접근성 다듬기 완료
> **작성**: [AI] Architect (소급 작성 — Developer 구현 후 게이트 보완) · **최종수정**: 2026-07-27 ([AI] Designer)
> **추적성** — Redmine: #545 · 관련 ADR: 없음
> · 구현 파일: `frontend/src/components/RestaurantDetail.tsx` · 테스트: 육안 확인(QA 잔여)
> · 구현 파일: `frontend/src/components/RestaurantDetail.tsx` · 테스트: `frontend/__tests__/RestaurantDetail.test.tsx` (5케이스, 육안 QA를 자동화 회귀 테스트로 대체)
## 1. 목적 (Why)
해외 식당 상세에 국내 전용 예약 서비스(테이블링/캐치테이블) 버튼이 노출되는 버그를 막는다.
@@ -17,7 +17,7 @@
- [x] 해외 식당(좌표가 한국 bbox 밖) 상세에서 테이블링/캐치테이블 버튼이 보이지 않는다.
- [x] 국내 식당 상세에서는 기존과 동일하게 두 버튼이 보인다 (URL 존재 시).
- [x] `tsc` 타입체크 통과.
- [ ] 육안 QA 확인 (해외/국내 식당 각 1건 이상, 대표 발견 케이스 포함).
- [x] QA 확인 (해외/국내 식당 각 1건 이상, 대표 발견 케이스 포함) — 자동화 회귀 테스트로 대체 검증 완료(`RestaurantDetail.test.tsx`).
## 4. 컨텍스트 & 제약
- 의존성: `Restaurant.latitude/longitude`, `Restaurant.region` (좌표 없는 구 데이터 fallback).
@@ -64,10 +64,13 @@ isKoreaRestaurant(r) ──false──▶ 테이블링/캐치테이블 버튼
- 좌표가 KR bbox 경계값에 걸치는 경우(제주/울릉도 등) → 기존 지도 링크 판정과 동일 로직이므로 별도 리스크 없음(기존에 검증된 임계값 재사용).
## 10. 테스트 계획
- 수동 QA: 해외 식당(예: 일본/방콕 소재) 상세 진입 → 테이블링/캐치테이블 버튼 미노출 확인.
- 수동 QA: 국내 식당 상세 진입 → 기존과 동일하게 버튼 노출 확인 (url 존재 시).
- `npm run build` / `tsc` 통과 (완료).
- 자동화 테스트: 없음 (컴포넌트 스냅샷/RTL 테스트 미비 — #543 커버리지 이슈에서 다룸).
- `tsc` 통과 (완료).
- 자동화 회귀 테스트(`frontend/__tests__/RestaurantDetail.test.tsx`, RTL) — [AI] QA 작성, 5케이스 전부 통과:
1. 국내 좌표(서울) + URL 존재 → 두 버튼 노출.
2. 해외 좌표(방콕, 대표 발견 케이스) + URL 존재 → 두 버튼 숨김.
3. 해외 좌표(도쿄) + URL 없음 → 숨김 유지(회귀 없음).
4. 좌표 없음 + region null → fallback으로 국내 간주, 버튼 노출(기존 동작 유지).
5. URL이 `"NONE"` 문자열 → 국내 식당이어도 버튼 없음(기존 게이트 유지).
## 11. 리스크 & 대안 검토
- 대안: 백엔드에서 해외 식당의 `tabling_url`/`catchtable_url`을 애초에 null로 채우지 않는 방법도 있으나, 프론트 게이트가 더 즉각적이고 기존 판정 로직 재사용 가능 → 채택.
@@ -75,3 +78,9 @@ isKoreaRestaurant(r) ──false──▶ 테이블링/캐치테이블 버튼
## 12. 미해결 질문 (Open Questions)
- 백엔드 예약 URL 수집/검색 단계에서 해외 식당을 원천 제외할지 여부는 별도 이슈로 분리할 가치가 있음 (설명에 "후속(옵션)"으로 명시됨).
## 13. Designer 다듬기 (표현/접점, 기능 변경 없음)
- 판정 로직(`isKoreaRestaurant`)과 노출 조건은 QA 통과 상태 그대로 — 변경하지 않음.
- 테이블링/캐치테이블 앵커에 `title` 속성 추가(`"… (새 탭)"`) — 새 탭 이동을 사전에 알려 예약 흐름 중 예기치 않은 이탈감 완화.
- 로고 대체용 단일 문자 배지(`T`/`C`)에 `aria-hidden="true"` 적용 — 스크린리더가 의미 없는 낱글자를 중복 낭독하지 않고 실제 라벨만 읽도록 정리.
- 회귀 확인: `tsc --noEmit` 통과, `frontend/__tests__/RestaurantDetail.test.tsx` 5/5 통과.

View File

@@ -0,0 +1,117 @@
/**
* #545 — 해외 식당 상세에서 테이블링/캐치테이블 예약 버튼 숨김 회귀 테스트.
* 좌표 기반(isKoreaRestaurant) 판정으로 국내/해외 식당에서 버튼 노출 여부가
* 올바르게 갈리는지 검증한다.
*/
import { act, render, screen } from "@testing-library/react";
import RestaurantDetail from "@/components/RestaurantDetail";
import type { Restaurant } from "@/lib/api";
// 컴포넌트 마운트 시 발생하는 비동기 데이터 로딩(영상/리뷰 등)을 플러시해
// act() 경고 없이 안정적으로 렌더 결과를 검증한다.
async function flush() {
await act(async () => {
await Promise.resolve();
});
}
jest.mock("@/lib/api", () => ({
getToken: jest.fn(() => null),
api: {
getRestaurantVideos: jest.fn(() => Promise.resolve([])),
getFavoriteStatus: jest.fn(() => Promise.resolve({ favorited: false })),
getReviews: jest.fn(() =>
Promise.resolve({ reviews: [], avg_rating: null, review_count: 0 })
),
getMemo: jest.fn(() => Promise.resolve(null)),
},
}));
function makeRestaurant(overrides: Partial<Restaurant>): Restaurant {
return {
id: "r1",
name: "테스트 식당",
address: null,
region: null,
latitude: 37.5,
longitude: 127.0,
cuisine_type: null,
price_range: null,
google_place_id: null,
tabling_url: null,
catchtable_url: null,
business_status: null,
rating: null,
rating_count: null,
phone: null,
website: null,
...overrides,
};
}
describe("RestaurantDetail — #545 해외 식당 예약 버튼 숨김", () => {
it("국내 식당(좌표 기반)이면 테이블링/캐치테이블 버튼이 보인다", async () => {
const restaurant = makeRestaurant({
latitude: 37.5665,
longitude: 126.978, // 서울
tabling_url: "https://tabling.example.com/1",
catchtable_url: "https://catchtable.example.com/1",
});
render(<RestaurantDetail restaurant={restaurant} onClose={() => {}} />);
await flush();
expect(screen.getByText("테이블링에서 줄서기")).toBeInTheDocument();
expect(screen.getByText("캐치테이블에서 예약하기")).toBeInTheDocument();
});
it("해외 식당(좌표가 KR bbox 밖)이면 두 버튼 모두 숨겨진다 (대표 발견 케이스)", async () => {
const restaurant = makeRestaurant({
latitude: 13.7563,
longitude: 100.5018, // 방콕
tabling_url: "https://tabling.example.com/1",
catchtable_url: "https://catchtable.example.com/1",
});
render(<RestaurantDetail restaurant={restaurant} onClose={() => {}} />);
await flush();
expect(screen.queryByText("테이블링에서 줄서기")).not.toBeInTheDocument();
expect(screen.queryByText("캐치테이블에서 예약하기")).not.toBeInTheDocument();
});
it("해외 식당이어도 예약 URL이 없으면 애초에 버튼이 없다 (회귀 없음 확인)", async () => {
const restaurant = makeRestaurant({
latitude: 35.6762,
longitude: 139.6503, // 도쿄
tabling_url: null,
catchtable_url: null,
});
render(<RestaurantDetail restaurant={restaurant} onClose={() => {}} />);
await flush();
expect(screen.queryByText("테이블링에서 줄서기")).not.toBeInTheDocument();
expect(screen.queryByText("캐치테이블에서 예약하기")).not.toBeInTheDocument();
});
it("좌표 없는 구 데이터는 region으로 fallback되어 국내로 간주된다 (기존 동작 유지)", async () => {
const restaurant = makeRestaurant({
latitude: undefined as unknown as number,
longitude: undefined as unknown as number,
region: null,
tabling_url: "https://tabling.example.com/1",
catchtable_url: null,
});
render(<RestaurantDetail restaurant={restaurant} onClose={() => {}} />);
await flush();
expect(screen.getByText("테이블링에서 줄서기")).toBeInTheDocument();
});
it("예약 URL이 'NONE' 문자열이면 국내 식당이어도 버튼이 없다 (기존 게이트 유지)", async () => {
const restaurant = makeRestaurant({
latitude: 37.5665,
longitude: 126.978,
tabling_url: "NONE",
catchtable_url: "NONE",
});
render(<RestaurantDetail restaurant={restaurant} onClose={() => {}} />);
await flush();
expect(screen.queryByText("테이블링에서 줄서기")).not.toBeInTheDocument();
expect(screen.queryByText("캐치테이블에서 예약하기")).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,36 @@
/**
* #533 — passesMinMentions() 순수 함수 단위 테스트.
* 설계서: docs/design/533-min-mentions-filter/README.md
*/
import { passesMinMentions } from "@/lib/filters";
describe("passesMinMentions", () => {
it("minMentions=0(기본)이면 필터 미적용 — 항상 true", () => {
expect(passesMinMentions(0, 0)).toBe(true);
expect(passesMinMentions(null, 0)).toBe(true);
expect(passesMinMentions(100, 0)).toBe(true);
});
it("음수 minMentions도 필터 미적용으로 취급한다", () => {
expect(passesMinMentions(0, -1)).toBe(true);
});
it("video_count 가 minMentions 미만이면 제외(false)", () => {
expect(passesMinMentions(1, 2)).toBe(false);
expect(passesMinMentions(0, 1)).toBe(false);
});
it("video_count 가 minMentions 와 같으면 포함(경계값 포함)", () => {
expect(passesMinMentions(2, 2)).toBe(true);
});
it("video_count 가 minMentions 초과면 포함", () => {
expect(passesMinMentions(5, 2)).toBe(true);
});
it("video_count 가 null/undefined(미산정)이면 0으로 간주한다", () => {
expect(passesMinMentions(null, 2)).toBe(false);
expect(passesMinMentions(undefined, 2)).toBe(false);
expect(passesMinMentions(null, 0)).toBe(true);
});
});

View File

@@ -0,0 +1,44 @@
/**
* #532 — formatRegion() 순수 함수 단위 테스트.
* 설계서: docs/design/532-region-display/README.md
*/
import { formatRegion } from "@/lib/region";
describe("formatRegion", () => {
it("falsy 입력은 빈 문자열", () => {
expect(formatRegion("")).toBe("");
expect(formatRegion(null)).toBe("");
expect(formatRegion(undefined)).toBe("");
});
it("파이프 구분 지역을 계층(breadcrumb)으로 변환한다", () => {
expect(formatRegion("한국|서울|강남구")).toBe("한국 서울 강남구");
});
it("'null' 토큰(대소문자 무시)은 제거한다", () => {
expect(formatRegion("일본|null")).toBe("일본");
expect(formatRegion("일본|NULL")).toBe("일본");
});
it("빈 토큰은 제거한다", () => {
expect(formatRegion("한국||관악구")).toBe("한국 관악구");
});
it("더미 토큰 '나라'는 제거한다", () => {
expect(formatRegion("나라|서울")).toBe("서울");
});
it("단일 토큰은 그대로 반환한다", () => {
expect(formatRegion("한국")).toBe("한국");
});
it("모든 토큰이 더미/빈 값이면 빈 문자열", () => {
expect(formatRegion("한국|")).toBe("한국");
expect(formatRegion("|")).toBe("");
expect(formatRegion("나라|null|")).toBe("");
});
it("토큰 앞뒤 공백은 trim 된다", () => {
expect(formatRegion(" 한국 | 서울 ")).toBe("한국 서울");
});
});

View File

@@ -15,6 +15,7 @@ import MyReviewsList from "@/components/MyReviewsList";
import BottomSheet from "@/components/BottomSheet";
import FilterSheet, { FilterOption } from "@/components/FilterSheet";
import { getCuisineIcon, getPhosphorCuisineIcon } from "@/lib/cuisine-icons";
import { passesMinMentions } from "@/lib/filters";
import Icon from "@/components/Icon";
import FoodIcon from "@/components/FoodIcon";
import * as PhosphorIcons from "@phosphor-icons/react";
@@ -249,7 +250,7 @@ export default function Home() {
if (channelFilter && !(r.channels || []).includes(channelFilter)) return false;
if (cuisineFilter && !matchCuisineFilter(r.cuisine_type, cuisineFilter)) return false;
if (priceFilter && !matchPriceGroup(r.price_range, priceFilter)) return false;
if (minMentions > 0 && (r.video_count ?? 0) < minMentions) return false;
if (!passesMinMentions(r.video_count, minMentions)) return false;
if (countryFilter) {
const parsed = parseRegion(r.region);
if (!parsed || parsed.country !== countryFilter) return false;
@@ -906,7 +907,7 @@ export default function Home() {
)}
</div>
{/* 필터 전체 해제 */}
{(channelFilter || cuisineFilter || priceFilter || countryFilter) && (
{(channelFilter || cuisineFilter || priceFilter || countryFilter || minMentions > 0) && (
<button
onClick={() => {
setChannelFilter("");
@@ -915,6 +916,7 @@ export default function Home() {
setCountryFilter("");
setCityFilter("");
setDistrictFilter("");
setMinMentions(0);
setRegionFlyTo(null);
}}
className="flex items-center gap-1 rounded-lg px-2.5 py-1.5 bg-gray-50 dark:bg-gray-800/50 text-gray-500 dark:text-gray-400 hover:text-brand-500 transition-colors touch-manipulation"
@@ -935,6 +937,7 @@ export default function Home() {
setCountryFilter("");
setCityFilter("");
setDistrictFilter("");
setMinMentions(0);
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
(pos) => { setUserLoc({ lat: pos.coords.latitude, lng: pos.coords.longitude }); setRegionFlyTo({ lat: pos.coords.latitude, lng: pos.coords.longitude, zoom: 15 }); },
@@ -1159,6 +1162,7 @@ export default function Home() {
setCountryFilter("");
setCityFilter("");
setDistrictFilter("");
setMinMentions(0);
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
(pos) => { setUserLoc({ lat: pos.coords.latitude, lng: pos.coords.longitude }); setRegionFlyTo({ lat: pos.coords.latitude, lng: pos.coords.longitude, zoom: 15 }); },
@@ -1271,6 +1275,7 @@ export default function Home() {
setCountryFilter("");
setCityFilter("");
setDistrictFilter("");
setMinMentions(0);
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
(pos) => { setUserLoc({ lat: pos.coords.latitude, lng: pos.coords.longitude }); setRegionFlyTo({ lat: pos.coords.latitude, lng: pos.coords.longitude, zoom: 15 }); },

View File

@@ -128,7 +128,7 @@ export default function RestaurantDetail({
<span className="text-gray-400 dark:text-gray-500"></span> <span className="text-gray-600 dark:text-gray-300">{restaurant.address}</span>
</p>
)}
{restaurant.region && (
{formatRegion(restaurant.region) && (
<p>
<span className="text-gray-400 dark:text-gray-500"></span> <span className="text-gray-600 dark:text-gray-300">{formatRegion(restaurant.region)}</span>
</p>
@@ -186,9 +186,10 @@ export default function RestaurantDetail({
href={restaurant.tabling_url}
target="_blank"
rel="noopener noreferrer"
title="테이블링에서 줄서기 (새 탭)"
className="flex items-center justify-center gap-2 w-full px-4 py-2.5 bg-rose-500 hover:bg-rose-600 text-white rounded-lg text-sm font-semibold transition-colors"
>
<span>T</span>
<span aria-hidden="true">T</span>
<span> </span>
</a>
)}
@@ -198,9 +199,10 @@ export default function RestaurantDetail({
href={restaurant.catchtable_url}
target="_blank"
rel="noopener noreferrer"
title="캐치테이블에서 예약하기 (새 탭)"
className="flex items-center justify-center gap-2 w-full px-4 py-2.5 bg-violet-500 hover:bg-violet-600 text-white rounded-lg text-sm font-semibold transition-colors"
>
<span>C</span>
<span aria-hidden="true">C</span>
<span> </span>
</a>
)}

View File

@@ -51,7 +51,7 @@ export default function RestaurantList({
<Icon name={getCuisineIcon(r.cuisine_type)} size={16} className="mr-0.5 text-brand-600" />
{r.name}
</h4>
{r.region && (
{formatRegion(r.region) && (
<span className="text-[11px] text-gray-400 dark:text-gray-500 truncate">{formatRegion(r.region)}</span>
)}
{r.rating && (

View File

@@ -0,0 +1,23 @@
// 클라이언트 필터 순수 함수 — #533
// page.tsx 의 filteredRestaurants 에서 사용하는 경계 로직을 테스트 가능하도록 분리.
/**
* "의미있는 언급 수(video_count)" 기준으로 식당을 포함할지 판정한다.
* 설계서: docs/design/533-min-mentions-filter/README.md
*
* - minMentions <= 0(기본값) → 필터 미적용, 항상 true.
* - videoCount 가 null/undefined(미산정) → 0 으로 간주.
* - videoCount >= minMentions 인 경우에만 true (경계값 포함).
*
* 예) passesMinMentions(1, 2) → false (1회는 2회 미만이라 제외)
* passesMinMentions(2, 2) → true (경계값 포함)
* passesMinMentions(null, 2) → false (미산정은 0 취급)
* passesMinMentions(0, 0) → true (필터 미적용)
*/
export function passesMinMentions(
videoCount: number | null | undefined,
minMentions: number
): boolean {
if (minMentions <= 0) return true;
return (videoCount ?? 0) >= minMentions;
}