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:
576
test/unit/data/datasources/remote/naver_search_service_test.dart
Normal file
576
test/unit/data/datasources/remote/naver_search_service_test.dart
Normal file
@@ -0,0 +1,576 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:lunchpick/data/datasources/remote/naver_search_service.dart';
|
||||
import 'package:lunchpick/data/datasources/remote/naver_map_parser.dart';
|
||||
import 'package:lunchpick/data/api/naver_api_client.dart';
|
||||
import 'package:lunchpick/data/api/naver/naver_local_search_api.dart';
|
||||
import 'package:lunchpick/domain/entities/restaurant.dart';
|
||||
import 'package:lunchpick/core/errors/network_exceptions.dart';
|
||||
|
||||
// Mock 클래스들
|
||||
class MockNaverApiClient extends NaverApiClient {
|
||||
final Map<String, dynamic> _mockResponses = {};
|
||||
final Map<String, Exception> _mockExceptions = {};
|
||||
|
||||
// Mock 설정 메서드들
|
||||
void setSearchResponse({
|
||||
required String query,
|
||||
double? latitude,
|
||||
double? longitude,
|
||||
required List<NaverLocalSearchResult> results,
|
||||
}) {
|
||||
final key = _generateKey(query, latitude, longitude);
|
||||
_mockResponses[key] = results;
|
||||
}
|
||||
|
||||
void setSearchException({
|
||||
required String query,
|
||||
double? latitude,
|
||||
double? longitude,
|
||||
required Exception exception,
|
||||
}) {
|
||||
final key = _generateKey(query, latitude, longitude);
|
||||
_mockExceptions[key] = exception;
|
||||
}
|
||||
|
||||
String _generateKey(String query, double? latitude, double? longitude) {
|
||||
return '$query-$latitude-$longitude';
|
||||
}
|
||||
|
||||
// 호출 추적
|
||||
final List<Map<String, dynamic>> callHistory = [];
|
||||
bool disposeCalled = false;
|
||||
|
||||
@override
|
||||
Future<List<NaverLocalSearchResult>> searchLocal({
|
||||
required String query,
|
||||
double? latitude,
|
||||
double? longitude,
|
||||
int display = 20,
|
||||
int start = 1,
|
||||
String sort = 'random',
|
||||
}) async {
|
||||
// 호출 기록
|
||||
callHistory.add({
|
||||
'query': query,
|
||||
'latitude': latitude,
|
||||
'longitude': longitude,
|
||||
'display': display,
|
||||
'sort': sort,
|
||||
});
|
||||
|
||||
final key = _generateKey(query, latitude, longitude);
|
||||
|
||||
if (_mockExceptions.containsKey(key)) {
|
||||
throw _mockExceptions[key]!;
|
||||
}
|
||||
|
||||
if (_mockResponses.containsKey(key)) {
|
||||
return _mockResponses[key] as List<NaverLocalSearchResult>;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
disposeCalled = true;
|
||||
}
|
||||
}
|
||||
|
||||
class MockNaverMapParser extends NaverMapParser {
|
||||
final Map<String, Restaurant> _mockResponses = {};
|
||||
final Map<String, Exception> _mockExceptions = {};
|
||||
|
||||
void setParseResponse(String url, Restaurant restaurant) {
|
||||
_mockResponses[url] = restaurant;
|
||||
}
|
||||
|
||||
void setParseException(String url, Exception exception) {
|
||||
_mockExceptions[url] = exception;
|
||||
}
|
||||
|
||||
// 호출 추적
|
||||
bool disposeCalled = false;
|
||||
final List<String> parseCallHistory = [];
|
||||
|
||||
@override
|
||||
Future<Restaurant> parseRestaurantFromUrl(
|
||||
String url, {
|
||||
double? userLatitude,
|
||||
double? userLongitude,
|
||||
}) async {
|
||||
parseCallHistory.add(url);
|
||||
|
||||
if (_mockExceptions.containsKey(url)) {
|
||||
throw _mockExceptions[url]!;
|
||||
}
|
||||
|
||||
if (_mockResponses.containsKey(url)) {
|
||||
return _mockResponses[url]!;
|
||||
}
|
||||
|
||||
throw NaverMapParseException('No mock response set for URL: $url');
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
disposeCalled = true;
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
late NaverSearchService service;
|
||||
late MockNaverApiClient mockApiClient;
|
||||
late MockNaverMapParser mockMapParser;
|
||||
|
||||
setUp(() {
|
||||
mockApiClient = MockNaverApiClient();
|
||||
mockMapParser = MockNaverMapParser();
|
||||
service = NaverSearchService(
|
||||
apiClient: mockApiClient,
|
||||
mapParser: mockMapParser,
|
||||
);
|
||||
});
|
||||
|
||||
group('NaverSearchService', () {
|
||||
group('getRestaurantFromUrl', () {
|
||||
const testUrl = 'https://map.naver.com/p/restaurant/1234567890';
|
||||
final testRestaurant = Restaurant(
|
||||
id: '1',
|
||||
name: 'Test Restaurant',
|
||||
category: '한식',
|
||||
subCategory: '백반',
|
||||
roadAddress: '서울시 강남구 테헤란로 123',
|
||||
jibunAddress: '서울시 강남구 역삼동 123-45',
|
||||
latitude: 37.123456,
|
||||
longitude: 127.123456,
|
||||
source: DataSource.NAVER,
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
|
||||
test('URL에서 식당 정보를 성공적으로 가져온다', () async {
|
||||
// Arrange
|
||||
mockMapParser.setParseResponse(testUrl, testRestaurant);
|
||||
|
||||
// Act
|
||||
final result = await service.getRestaurantFromUrl(testUrl);
|
||||
|
||||
// Assert
|
||||
expect(result, equals(testRestaurant));
|
||||
expect(mockMapParser.parseCallHistory, contains(testUrl));
|
||||
});
|
||||
|
||||
test('NaverMapParseException을 그대로 전파한다', () async {
|
||||
// Arrange
|
||||
final exception = NaverMapParseException('파싱 실패');
|
||||
mockMapParser.setParseException(testUrl, exception);
|
||||
|
||||
// Act & Assert
|
||||
expect(
|
||||
() async => await service.getRestaurantFromUrl(testUrl),
|
||||
throwsA(isA<NaverMapParseException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('NetworkException을 그대로 전파한다', () async {
|
||||
// Arrange
|
||||
const exception = ServerException(message: '네트워크 오류', statusCode: 500);
|
||||
mockMapParser.setParseException(testUrl, exception);
|
||||
|
||||
// Act & Assert
|
||||
expect(
|
||||
() async => await service.getRestaurantFromUrl(testUrl),
|
||||
throwsA(isA<NetworkException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('일반 예외를 NetworkException으로 래핑한다', () async {
|
||||
// Arrange
|
||||
final exception = Exception('알 수 없는 오류');
|
||||
mockMapParser.setParseException(testUrl, exception);
|
||||
|
||||
// Act & Assert
|
||||
expect(
|
||||
() async => await service.getRestaurantFromUrl(testUrl),
|
||||
throwsA(
|
||||
allOf(
|
||||
isA<ParseException>(),
|
||||
predicate<ParseException>((e) =>
|
||||
e.message.contains('식당 정보를 가져올 수 없습니다') &&
|
||||
e.originalError.toString() == exception.toString()
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('searchNearbyRestaurants', () {
|
||||
const testQuery = '김치찌개';
|
||||
const testLatitude = 37.123456;
|
||||
const testLongitude = 127.123456;
|
||||
|
||||
final testSearchResults = [
|
||||
NaverLocalSearchResult.fromJson({
|
||||
'title': '김치찌개 맛집',
|
||||
'category': '한식>찌개',
|
||||
'description': '맛있는 김치찌개',
|
||||
'telephone': '02-1234-5678',
|
||||
'address': '서울시 강남구 역삼동',
|
||||
'roadAddress': '서울시 강남구 테헤란로 123',
|
||||
'mapx': 127123456,
|
||||
'mapy': 37123456,
|
||||
'link': 'https://map.naver.com/p/restaurant/1234567890',
|
||||
}),
|
||||
];
|
||||
|
||||
test('검색 결과를 Restaurant 리스트로 변환한다', () async {
|
||||
// Arrange
|
||||
mockApiClient.setSearchResponse(
|
||||
query: testQuery,
|
||||
latitude: testLatitude,
|
||||
longitude: testLongitude,
|
||||
results: testSearchResults,
|
||||
);
|
||||
|
||||
// Act
|
||||
final results = await service.searchNearbyRestaurants(
|
||||
query: testQuery,
|
||||
latitude: testLatitude,
|
||||
longitude: testLongitude,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(results.length, equals(1));
|
||||
expect(results.first.name, equals('김치찌개 맛집'));
|
||||
expect(results.first.category, equals('한식'));
|
||||
expect(results.first.subCategory, equals('찌개'));
|
||||
|
||||
// API 호출 확인
|
||||
expect(mockApiClient.callHistory.length, equals(1));
|
||||
expect(mockApiClient.callHistory.first['query'], equals(testQuery));
|
||||
expect(mockApiClient.callHistory.first['sort'], equals('random'));
|
||||
});
|
||||
|
||||
test('빈 검색 결과를 처리한다', () async {
|
||||
// Arrange
|
||||
mockApiClient.setSearchResponse(
|
||||
query: testQuery,
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
results: [],
|
||||
);
|
||||
|
||||
// Act
|
||||
final results = await service.searchNearbyRestaurants(query: testQuery);
|
||||
|
||||
// Assert
|
||||
expect(results, isEmpty);
|
||||
});
|
||||
|
||||
test('NetworkException을 그대로 전파한다', () async {
|
||||
// Arrange
|
||||
const exception = ServerException(message: '네트워크 오류', statusCode: 500);
|
||||
mockApiClient.setSearchException(
|
||||
query: testQuery,
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
exception: exception,
|
||||
);
|
||||
|
||||
// Act & Assert
|
||||
expect(
|
||||
() async => await service.searchNearbyRestaurants(query: testQuery),
|
||||
throwsA(isA<NetworkException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('일반 예외를 NetworkException으로 래핑한다', () async {
|
||||
// Arrange
|
||||
final exception = Exception('알 수 없는 오류');
|
||||
mockApiClient.setSearchException(
|
||||
query: testQuery,
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
exception: exception,
|
||||
);
|
||||
|
||||
// Act & Assert
|
||||
expect(
|
||||
() async => await service.searchNearbyRestaurants(query: testQuery),
|
||||
throwsA(
|
||||
allOf(
|
||||
isA<ParseException>(),
|
||||
predicate<ParseException>((e) =>
|
||||
e.message.contains('식당 검색에 실패했습니다') &&
|
||||
e.originalError.toString() == exception.toString()
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('searchRestaurantDetails', () {
|
||||
const testName = '김치찌개 맛집';
|
||||
const testAddress = '서울시 강남구 역삼동';
|
||||
const testLatitude = 37.123456;
|
||||
const testLongitude = 127.123456;
|
||||
|
||||
final testSearchResults = [
|
||||
NaverLocalSearchResult.fromJson({
|
||||
'title': testName,
|
||||
'category': '한식>찌개',
|
||||
'description': '맛있는 김치찌개',
|
||||
'telephone': '02-1234-5678',
|
||||
'address': testAddress,
|
||||
'roadAddress': '서울시 강남구 테헤란로 123',
|
||||
'mapx': 127123456,
|
||||
'mapy': 37123456,
|
||||
'link': 'https://map.naver.com/p/restaurant/1234567890',
|
||||
}),
|
||||
];
|
||||
|
||||
test('정확히 일치하는 식당을 찾는다', () async {
|
||||
// Arrange
|
||||
mockApiClient.setSearchResponse(
|
||||
query: '서울시 강남구 $testName',
|
||||
latitude: testLatitude,
|
||||
longitude: testLongitude,
|
||||
results: testSearchResults,
|
||||
);
|
||||
|
||||
// Act
|
||||
final result = await service.searchRestaurantDetails(
|
||||
name: testName,
|
||||
address: testAddress,
|
||||
latitude: testLatitude,
|
||||
longitude: testLongitude,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(result, isNotNull);
|
||||
expect(result!.name, equals(testName));
|
||||
});
|
||||
|
||||
test('검색 결과가 없으면 null을 반환한다', () async {
|
||||
// Arrange
|
||||
mockApiClient.setSearchResponse(
|
||||
query: testName,
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
results: [],
|
||||
);
|
||||
|
||||
// Act
|
||||
final result = await service.searchRestaurantDetails(
|
||||
name: testName,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(result, isNull);
|
||||
});
|
||||
|
||||
test('유사도가 낮은 결과는 null을 반환한다', () async {
|
||||
// Arrange
|
||||
final unmatchedResults = [
|
||||
NaverLocalSearchResult.fromJson({
|
||||
'title': '완전히 다른 식당',
|
||||
'category': '양식',
|
||||
'description': '파스타',
|
||||
'telephone': '02-9999-9999',
|
||||
'address': '서울시 종로구',
|
||||
'roadAddress': '서울시 종로구 세종대로 99',
|
||||
'mapx': 127999999,
|
||||
'mapy': 37999999,
|
||||
'link': 'https://map.naver.com/p/restaurant/9999999999',
|
||||
}),
|
||||
];
|
||||
|
||||
mockApiClient.setSearchResponse(
|
||||
query: testName,
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
results: unmatchedResults,
|
||||
);
|
||||
|
||||
// Act
|
||||
final result = await service.searchRestaurantDetails(
|
||||
name: testName,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(result, isNull);
|
||||
});
|
||||
|
||||
test('네이버 지도 URL이 있으면 상세 정보를 파싱한다', () async {
|
||||
// Arrange
|
||||
final detailedRestaurant = Restaurant(
|
||||
id: '2',
|
||||
name: testName,
|
||||
category: '한식',
|
||||
subCategory: '찌개',
|
||||
description: '업데이트된 설명',
|
||||
businessHours: '09:00 - 21:00',
|
||||
naverPlaceId: '1234567890',
|
||||
roadAddress: '서울시 강남구 테헤란로 123',
|
||||
jibunAddress: '서울시 강남구 역삼동 123-45',
|
||||
latitude: testLatitude,
|
||||
longitude: testLongitude,
|
||||
source: DataSource.NAVER,
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
|
||||
mockApiClient.setSearchResponse(
|
||||
query: testName,
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
results: testSearchResults,
|
||||
);
|
||||
|
||||
mockMapParser.setParseResponse(
|
||||
'https://map.naver.com/p/restaurant/1234567890',
|
||||
detailedRestaurant,
|
||||
);
|
||||
|
||||
// Act
|
||||
final result = await service.searchRestaurantDetails(name: testName);
|
||||
|
||||
// Assert
|
||||
expect(result, isNotNull);
|
||||
expect(result!.description, equals('업데이트된 설명'));
|
||||
expect(result.businessHours, equals('09:00 - 21:00'));
|
||||
expect(result.naverPlaceId, equals('1234567890'));
|
||||
});
|
||||
|
||||
test('상세 파싱 실패해도 기본 정보를 반환한다', () async {
|
||||
// Arrange
|
||||
mockApiClient.setSearchResponse(
|
||||
query: testName,
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
results: testSearchResults,
|
||||
);
|
||||
|
||||
mockMapParser.setParseException(
|
||||
'https://map.naver.com/p/restaurant/1234567890',
|
||||
Exception('파싱 실패'),
|
||||
);
|
||||
|
||||
// Act
|
||||
final result = await service.searchRestaurantDetails(name: testName);
|
||||
|
||||
// Assert
|
||||
expect(result, isNotNull);
|
||||
expect(result!.name, equals(testName));
|
||||
});
|
||||
});
|
||||
|
||||
group('_findBestMatch', () {
|
||||
test('정확히 일치하는 결과를 우선 반환한다', () {
|
||||
// Arrange
|
||||
const targetName = '김치찌개 맛집';
|
||||
final results = [
|
||||
NaverLocalSearchResult.fromJson({
|
||||
'title': '다른 식당',
|
||||
'category': '한식',
|
||||
'description': '',
|
||||
'telephone': '',
|
||||
'address': '',
|
||||
'roadAddress': '',
|
||||
'mapx': 0,
|
||||
'mapy': 0,
|
||||
'link': '',
|
||||
}),
|
||||
NaverLocalSearchResult.fromJson({
|
||||
'title': targetName,
|
||||
'category': '한식',
|
||||
'description': '',
|
||||
'telephone': '',
|
||||
'address': '',
|
||||
'roadAddress': '',
|
||||
'mapx': 0,
|
||||
'mapy': 0,
|
||||
'link': '',
|
||||
}),
|
||||
];
|
||||
|
||||
// Act
|
||||
final result = service.findBestMatchForTesting(targetName, results);
|
||||
|
||||
// Assert
|
||||
expect(result?.title, equals(targetName));
|
||||
});
|
||||
|
||||
test('빈 리스트에서는 null을 반환한다', () {
|
||||
// Act
|
||||
final result = service.findBestMatchForTesting('test', []);
|
||||
|
||||
// Assert
|
||||
expect(result, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('_calculateSimilarity', () {
|
||||
test('동일한 문자열은 1.0을 반환한다', () {
|
||||
// Act
|
||||
final similarity = service.calculateSimilarityForTesting('테스트', '테스트');
|
||||
|
||||
// Assert
|
||||
expect(similarity, greaterThan(0.7));
|
||||
});
|
||||
|
||||
test('포함 관계가 있으면 0.8을 반환한다', () {
|
||||
// Act
|
||||
final similarity = service.calculateSimilarityForTesting(
|
||||
'김치찌개',
|
||||
'김치찌개 맛집',
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(similarity, equals(0.8));
|
||||
});
|
||||
|
||||
test('완전히 다른 문자열은 낮은 유사도를 반환한다', () {
|
||||
// Act
|
||||
final similarity = service.calculateSimilarityForTesting('한식', '양식');
|
||||
|
||||
// Assert
|
||||
expect(similarity, lessThan(0.5));
|
||||
});
|
||||
|
||||
test('빈 문자열은 0.0을 반환한다', () {
|
||||
// Act
|
||||
final similarity = service.calculateSimilarityForTesting('', 'test');
|
||||
|
||||
// Assert
|
||||
expect(similarity, equals(0.0));
|
||||
});
|
||||
|
||||
test('특수문자를 제거하고 비교한다', () {
|
||||
// Act
|
||||
final similarity = service.calculateSimilarityForTesting(
|
||||
'김치찌개@#\$',
|
||||
'김치찌개!!!',
|
||||
);
|
||||
|
||||
// Assert
|
||||
// 특수문자가 제거된 후 동일한 문자열이므로 0.8 이상이어야 함
|
||||
expect(similarity, greaterThanOrEqualTo(0.8));
|
||||
});
|
||||
});
|
||||
|
||||
group('dispose', () {
|
||||
test('dispose가 정상적으로 호출된다', () {
|
||||
// Act
|
||||
service.dispose();
|
||||
|
||||
// Assert
|
||||
expect(mockApiClient.disposeCalled, isTrue);
|
||||
expect(mockMapParser.disposeCalled, isTrue);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user