feat: 초기 프로젝트 설정 및 LunchPick 앱 구현

LunchPick(오늘 뭐 먹Z?) Flutter 앱의 초기 구현입니다.

주요 기능:
- 네이버 지도 연동 맛집 추가
- 랜덤 메뉴 추천 시스템
- 날씨 기반 거리 조정
- 방문 기록 관리
- Bluetooth 맛집 공유
- 다크모드 지원

기술 스택:
- Flutter 3.8.1+
- Riverpod 상태 관리
- Hive 로컬 DB
- Clean Architecture

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
JiWoong Sul
2025-07-30 19:03:28 +09:00
commit 85fde36157
237 changed files with 30953 additions and 0 deletions

View File

@@ -0,0 +1,360 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:lunchpick/data/datasources/remote/naver_map_parser.dart';
import '../mocks/mock_naver_api_client.dart';
void main() {
group('NaverMapParser - 네이버 API 통합 테스트', () {
test('네이버 로컬 API 응답 시뮬레이션', () async {
// 실제 네이버 로컬 API 응답 형식을 모방
final mockApiClient = MockNaverApiClient();
// HTML 응답 설정
final htmlContent = '''
<html>
<head>
<meta property="og:title" content="맛있는 김치찌개">
<meta property="og:url" content="https://map.naver.com/p/restaurant/1234567890?y=37.5666805&x=126.9784147">
</head>
<body>
<span class="GHAhO">맛있는 김치찌개</span>
<span class="DJJvD">한식 > 김치찌개</span>
<span class="IH7VW">서울특별시 종로구 세종대로 110</span>
<span class="xlx7Q">02-1234-5678</span>
<time class="aT6WB">매일 10:30 - 21:00</time>
<div class="description">35년 전통의 김치찌개 전문점입니다.</div>
</body>
</html>
''';
mockApiClient.setHtmlResponse(
'https://map.naver.com/p/restaurant/1234567890',
htmlContent,
);
// GraphQL 응답 설정
mockApiClient.setGraphQLResponse({
'place': {
'id': '1234567890',
'name': '맛있는 김치찌개',
'category': '한식>김치찌개',
'address': '서울특별시 종로구 세종대로 110',
'roadAddress': '서울특별시 종로구 세종대로 110',
'phone': '02-1234-5678',
'businessHours': {
'description': '매일 10:30 - 21:00',
},
'location': {
'lat': 37.5666805,
'lng': 126.9784147,
},
},
});
final parser = NaverMapParser(apiClient: mockApiClient);
// 네이버 지도 URL로 파싱
final restaurant = await parser.parseRestaurantFromUrl(
'https://map.naver.com/p/restaurant/1234567890',
);
// API 응답과 HTML 파싱 결과가 일치하는지 확인
expect(restaurant.name, '맛있는 김치찌개');
expect(restaurant.category, '한식');
expect(restaurant.subCategory, '김치찌개');
expect(restaurant.phoneNumber, '02-1234-5678');
expect(restaurant.roadAddress, '서울특별시 종로구 세종대로 110');
expect(restaurant.businessHours, '매일 10:30 - 21:00');
// 좌표 변환이 올바른지 확인
expect(restaurant.latitude, closeTo(37.5666805, 0.0000001));
expect(restaurant.longitude, closeTo(126.9784147, 0.0000001));
});
test('좌표 변환 정확성 테스트', () async {
final testCases = [
{
'mapx': '1269784147',
'mapy': '375666805',
'expectedLat': 37.5666805,
'expectedLng': 126.9784147,
},
{
'mapx': '1270333333',
'mapy': '374999999',
'expectedLat': 37.4999999,
'expectedLng': 127.0333333,
},
];
for (final testCase in testCases) {
final mockApiClient = MockNaverApiClient();
final htmlContent = '''
<html>
<head>
<meta property="og:url" content="https://map.naver.com/p/restaurant/1234567890?y=${testCase['expectedLat']}&x=${testCase['expectedLng']}">
</head>
<body>
<span class="GHAhO">좌표 테스트 식당</span>
</body>
</html>
''';
mockApiClient.setHtmlResponse(
'https://map.naver.com/p/restaurant/1234567890',
htmlContent,
);
// GraphQL 응답도 설정
mockApiClient.setGraphQLResponse({
'place': {
'id': '1234567890',
'name': '좌표 테스트 식당',
'location': {
'lat': testCase['expectedLat'],
'lng': testCase['expectedLng'],
},
},
});
final parser = NaverMapParser(apiClient: mockApiClient);
final restaurant = await parser.parseRestaurantFromUrl(
'https://map.naver.com/p/restaurant/1234567890',
);
expect(
restaurant.latitude,
closeTo(testCase['expectedLat'] as double, 0.0000001),
reason: '위도 변환이 정확해야 함',
);
expect(
restaurant.longitude,
closeTo(testCase['expectedLng'] as double, 0.0000001),
reason: '경도 변환이 정확해야 함',
);
}
});
test('카테고리 정규화 테스트', () async {
final categoryTests = [
{'input': '한식>김치찌개', 'expectedMain': '한식', 'expectedSub': '김치찌개'},
{'input': '카페,디저트', 'expectedMain': '카페', 'expectedSub': '카페'},
{'input': '양식 > 파스타', 'expectedMain': '양식', 'expectedSub': '파스타'},
{'input': '중식', 'expectedMain': '중식', 'expectedSub': '중식'},
];
for (final test in categoryTests) {
final mockApiClient = MockNaverApiClient();
final htmlContent = '''
<html>
<body>
<span class="GHAhO">카테고리 테스트</span>
<span class="DJJvD">${test['input']}</span>
</body>
</html>
''';
mockApiClient.setHtmlResponse(
'https://map.naver.com/p/restaurant/1234567890',
htmlContent,
);
final parser = NaverMapParser(apiClient: mockApiClient);
final restaurant = await parser.parseRestaurantFromUrl(
'https://map.naver.com/p/restaurant/1234567890',
);
expect(
restaurant.category,
test['expectedMain'],
reason: '메인 카테고리가 올바르게 추출되어야 함: ${test['input']}',
);
expect(
restaurant.subCategory,
test['expectedSub'],
reason: '서브 카테고리가 올바르게 추출되어야 함: ${test['input']}',
);
}
});
test('HTML 엔티티 디코딩 테스트', () async {
final mockApiClient = MockNaverApiClient();
final htmlContent = '''
<html>
<body>
<span class="GHAhO">Tom &amp; Jerry&apos;s Restaurant</span>
<span class="IH7VW">서울시 강남구 &lt;테헤란로&gt; 123번지</span>
<span class="description">&quot;최고의 맛&quot;을 자랑하는 레스토랑</span>
</body>
</html>
''';
mockApiClient.setHtmlResponse(
'https://map.naver.com/p/restaurant/1234567890',
htmlContent,
);
final parser = NaverMapParser(apiClient: mockApiClient);
final restaurant = await parser.parseRestaurantFromUrl(
'https://map.naver.com/p/restaurant/1234567890',
);
expect(restaurant.name, contains('&'));
expect(restaurant.name, contains("'"));
expect(restaurant.roadAddress, contains('<'));
expect(restaurant.roadAddress, contains('>'));
});
test('영업시간 파싱 다양성 테스트', () async {
final businessHourTests = [
'매일 11:00 - 22:00',
'월-금 11:30 - 21:00, 주말 12:00 - 22:00',
'연중무휴 24시간',
'화요일 휴무, 그 외 10:00 - 20:00',
'평일 11:00~14:00, 17:00~22:00 (브레이크타임 14:00~17:00)',
];
for (final hours in businessHourTests) {
final mockApiClient = MockNaverApiClient();
final htmlContent = '''
<html>
<body>
<span class="GHAhO">영업시간 테스트</span>
<time class="aT6WB">$hours</time>
</body>
</html>
''';
mockApiClient.setHtmlResponse(
'https://map.naver.com/p/restaurant/1234567890',
htmlContent,
);
final parser = NaverMapParser(apiClient: mockApiClient);
final restaurant = await parser.parseRestaurantFromUrl(
'https://map.naver.com/p/restaurant/1234567890',
);
expect(
restaurant.businessHours,
hours,
reason: '영업시간이 정확히 파싱되어야 함',
);
}
});
test('Place ID 추출 패턴 테스트', () async {
final urlPatterns = [
{
'url': 'https://map.naver.com/p/restaurant/1234567890',
'expectedId': '1234567890',
},
{
'url': 'https://map.naver.com/p/entry/place/9876543210',
'expectedId': '9876543210',
},
{
'url': 'https://map.naver.com/p/restaurant/1234567890?entry=pll',
'expectedId': '1234567890',
},
];
for (final pattern in urlPatterns) {
final mockApiClient = MockNaverApiClient();
final htmlContent = '''
<html>
<body>
<span class="GHAhO">Place ID 테스트</span>
</body>
</html>
''';
mockApiClient.setHtmlResponse(
pattern['url']!,
htmlContent,
);
final parser = NaverMapParser(apiClient: mockApiClient);
final restaurant = await parser.parseRestaurantFromUrl(pattern['url']!);
expect(
restaurant.naverPlaceId,
pattern['expectedId'],
reason: 'Place ID가 올바르게 추출되어야 함: ${pattern['url']}',
);
}
});
});
group('NaverMapParser - 동시성 및 리소스 관리', () {
test('동시 다중 요청 처리', () async {
final mockApiClient = MockNaverApiClient();
final parser = NaverMapParser(apiClient: mockApiClient);
// 동시에 여러 요청 실행
final futures = List.generate(5, (i) {
final url = 'https://map.naver.com/p/restaurant/${1000 + i}';
// 각 URL에 대한 HTML 응답 설정
mockApiClient.setHtmlResponse(
url,
'''
<html>
<body>
<span class="GHAhO">동시성 테스트 식당 ${i + 1}</span>
<span class="DJJvD">한식</span>
</body>
</html>
''',
);
return parser.parseRestaurantFromUrl(url);
});
final results = await Future.wait(futures);
// 모든 요청이 성공했는지 확인
expect(results.length, 5);
// 각 결과가 고유한지 확인
final names = results.map((r) => r.name).toSet();
expect(names.length, 5);
});
test('리소스 정리 확인', () async {
final mockApiClient = MockNaverApiClient();
mockApiClient.setHtmlResponse(
'https://map.naver.com/p/restaurant/123456789',
'<html><body><span class="GHAhO">Test</span></body></html>',
);
final parser = NaverMapParser(apiClient: mockApiClient);
// 여러 번 사용
for (int i = 0; i < 3; i++) {
try {
await parser.parseRestaurantFromUrl(
'https://map.naver.com/p/restaurant/123456789'
);
} catch (_) {
// 에러 무시
}
}
// dispose 호출
parser.dispose();
// dispose 후에는 사용할 수 없어야 함
expect(
() => parser.parseRestaurantFromUrl('https://map.naver.com/p/restaurant/999'),
throwsA(anything),
);
});
});
}

View File

@@ -0,0 +1,146 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:lunchpick/data/api/naver_api_client.dart';
import 'package:lunchpick/data/datasources/remote/naver_map_parser.dart';
import 'package:lunchpick/data/datasources/remote/naver_html_extractor.dart';
void main() {
late NaverApiClient apiClient;
late NaverMapParser parser;
setUp(() {
apiClient = NaverApiClient();
parser = NaverMapParser(apiClient: apiClient);
});
tearDown(() {
apiClient.dispose();
parser.dispose();
});
group('네이버 통합 테스트', () {
test('단축 URL 자동 처리 테스트', () async {
// 실제 단축 URL로 테스트
const shortUrl = 'https://naver.me/example'; // 실제 URL로 교체 필요
try {
print('========== 단축 URL 자동 처리 테스트 ==========');
print('입력 URL: $shortUrl');
// NaverMapParser를 통한 자동 처리
final restaurant = await parser.parseRestaurantFromUrl(shortUrl);
print('\n【파싱 결과】');
print('상호명: ${restaurant.name}');
print('카테고리: ${restaurant.category} > ${restaurant.subCategory}');
print('주소: ${restaurant.roadAddress}');
print('전화: ${restaurant.phoneNumber ?? "없음"}');
print('설명: ${restaurant.description ?? "없음"}');
print('좌표: ${restaurant.latitude}, ${restaurant.longitude}');
print('Place ID: ${restaurant.naverPlaceId}');
print('URL: ${restaurant.naverUrl}');
// 검증
expect(restaurant.name, isNotEmpty);
expect(restaurant.category, isNotEmpty);
expect(restaurant.roadAddress, isNotEmpty);
expect(restaurant.naverPlaceId, isNotEmpty);
expect(restaurant.source.name, equals('NAVER'));
print('\n✓ 테스트 성공');
} catch (e) {
print('\n❌ 테스트 실패: $e');
rethrow;
}
});
test('HTML 추출기 테스트', () async {
const testHtml = '''
<html>
<head>
<script type="application/ld+json">
{
"@type": "Restaurant",
"name": "테스트 식당"
}
</script>
</head>
<body>
<h1>테스트 식당</h1>
<p>서울특별시 강남구 테헤란로 123</p>
<span>한식</span>
</body>
</html>
''';
print('\n========== HTML 추출기 테스트 ==========');
// 한글 텍스트 추출
final koreanTexts = NaverHtmlExtractor.extractAllValidKoreanTexts(testHtml);
print('추출된 한글 텍스트: $koreanTexts');
expect(koreanTexts, isNotEmpty);
// JSON-LD 추출
final jsonLdName = NaverHtmlExtractor.extractPlaceNameFromJsonLd(testHtml);
print('JSON-LD 상호명: $jsonLdName');
expect(jsonLdName, equals('테스트 식당'));
print('\n✓ 테스트 성공');
});
test('로컬 검색 API 테스트', () async {
print('\n========== 로컬 검색 API 테스트 ==========');
const query = '스타벅스 강남역점';
try {
final results = await apiClient.searchLocal(
query: query,
display: 5,
);
print('검색어: "$query"');
print('결과 수: ${results.length}\n');
for (int i = 0; i < results.length; i++) {
final result = results[i];
print('${i + 1}. ${result.title}');
print(' 카테고리: ${result.category}');
print(' 주소: ${result.roadAddress}');
print(' 좌표: ${result.mapx}, ${result.mapy}');
}
expect(results, isNotEmpty);
print('\n✓ 테스트 성공');
} catch (e) {
print('\n❌ 테스트 실패: $e');
rethrow;
}
});
test('성능 테스트 - 단축 URL 처리 시간', () async {
const shortUrl = 'https://naver.me/example'; // 실제 URL로 교체 필요
print('\n========== 성능 테스트 ==========');
final stopwatch = Stopwatch()..start();
try {
final restaurant = await parser.parseRestaurantFromUrl(shortUrl);
stopwatch.stop();
print('처리 완료: ${restaurant.name}');
print('소요 시간: ${stopwatch.elapsedMilliseconds}ms');
// 5초 이내 처리 확인
expect(stopwatch.elapsedMilliseconds, lessThan(5000));
print('\n✓ 테스트 성공');
} catch (e) {
stopwatch.stop();
print('처리 실패 (${stopwatch.elapsedMilliseconds}ms)');
print('오류: $e');
rethrow;
}
});
});
}