Compare commits
7 Commits
e0e9ab6936
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aba6fac2ae | ||
|
|
63354fa63a | ||
|
|
09ddc47103 | ||
|
|
77aad4ab14 | ||
|
|
935c73129b | ||
|
|
1815a4c4b2 | ||
|
|
c13b976ca3 |
58
CHANGELOG.md
58
CHANGELOG.md
@@ -6,11 +6,59 @@
|
||||
|
||||
## 2026-07-27
|
||||
|
||||
### 🎨 [Designer] #545 해외 식당 예약 버튼 접근성/명확성 다듬기
|
||||
- 기능 동작 변경 없음(버튼 노출 게이트는 QA 통과된 `isKoreaRestaurant()` 그대로 유지) — 표현/접점만 보완
|
||||
- 테이블링/캐치테이블 링크에 `title` 속성 추가("… (새 탭)") — 새 탭으로 이동함을 사전에 알려 예약 도중 예기치 않은 이탈감을 줄임
|
||||
- 로고 대체용 단일 문자(`T`, `C`)에 `aria-hidden="true"` 적용 — 스크린리더가 의미 없는 낱글자("T", "C")를 중복 낭독하지 않고 실제 라벨("테이블링에서 줄서기" 등)만 읽도록 정리
|
||||
- `tsc --noEmit` 통과, `RestaurantDetail.test.tsx` 5/5 통과(회귀 없음)
|
||||
### ✅ [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 해외 식당 예약 버튼 숨김 — 검증 통과
|
||||
|
||||
@@ -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) &&` 가드로 `""`이면 미표시.
|
||||
- 단일 토큰(`"한국"`) → `한국` 그대로.
|
||||
|
||||
## 검증
|
||||
|
||||
@@ -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`로 안전.
|
||||
|
||||
36
frontend/__tests__/filters.test.ts
Normal file
36
frontend/__tests__/filters.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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 }); },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
23
frontend/src/lib/filters.ts
Normal file
23
frontend/src/lib/filters.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user