Compare commits

...

2 Commits

Author SHA1 Message Date
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
7 changed files with 84 additions and 13 deletions

View File

@@ -6,11 +6,21 @@
## 2026-07-27
### 🎨 [Designer] #545 해외 식당 예약 버튼 접근성/명확성 다듬기
- 기능 동작 변경 없음(버튼 노출 게이트는 QA 통과된 `isKoreaRestaurant()` 그대로 유지) — 표현/접점만 보완
- 테이블링/캐치테이블 링크에 `title` 속성 추가("… (새 탭)") — 새 탭으로 이동함을 사전에 알려 예약 도중 예기치 않은 이탈감을 줄임
- 로고 대체용 단일 문자(`T`, `C`)에 `aria-hidden="true"` 적용 — 스크린리더가 의미 없는 낱글자("T", "C")를 중복 낭독하지 않고 실제 라벨("테이블링에서 줄서기" 등)만 읽도록 정리
- `tsc --noEmit` 통과, `RestaurantDetail.test.tsx` 5/5 통과(회귀 없음)
### [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 해외 식당 예약 버튼 숨김 — 검증 통과

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

@@ -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

@@ -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;

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,10 +186,9 @@ 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 aria-hidden="true">T</span>
<span>T</span>
<span> </span>
</a>
)}
@@ -199,10 +198,9 @@ 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 aria-hidden="true">C</span>
<span>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;
}