feat(flutter): 장소 검색·상세 화면과 홈 검색창·카테고리·반려동물 장소 칸
CI (Flutter) / Flutter (analyze · test) (pull_request) Successful in 21m21s
CI / Backend (lint · test · e2e · build) (pull_request) Successful in 12m16s
CI / Frontend (lint · type-check · test) (pull_request) Has been cancelled
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Has been cancelled

디자인 캔버스 4(검색)·6·7(장소 상세) 중 지금 장소 API 로 되는 부분을 붙였다.

- 검색은 반경 20km(백엔드 최대) 거리순. 대구 도심에서 대구 전역이 들어온다.
- 영업시간을 모르는 곳은 "영업시간 미확인 · 방문 전 확인"으로 보인다.
- 한눈에 보기는 공공데이터 원문으로 확실한 것만 올린다(주차 '인근 공영주차장' 같은 애매한 값은 빼기).
- 함께 가기 좋은 곳에는 확인 안 된 상가정보 매장을 넣지 않는다(2026-11-02 합의).
- 1330 관광통역안내는 영어 화면에만(2026-10-29 합의).

검색의 '축제' 탭과 실내·무료·주차·이번 주말 조건은 데이터가 없어 넣지 않았다
(행사 API 검색어 조건 · 운영자 확인 장소 · 날씨).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-04 16:44:56 +09:00
parent 8f711c1706
commit 4bd142a9b7
29 changed files with 3816 additions and 45 deletions
+10
View File
@@ -50,4 +50,14 @@ void main() {
expect(shortRegion('대구광역시'), isNull);
});
});
group('formatSourceDate', () {
test('상가정보 기준년월 YYYYMM 은 YYYY-MM 으로', () {
expect(formatSourceDate('202609'), '2026-09');
});
test('날짜는 그대로', () {
expect(formatSourceDate('2026-06-26'), '2026-06-26');
});
});
}
+41
View File
@@ -0,0 +1,41 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:wandergo/core/utils/geo.dart';
void main() {
group('distanceMeters', () {
test('같은 좌표는 0', () {
expect(distanceMeters(35.87, 128.59, 35.87, 128.59), 0);
});
test('대구 도심 동성로 ↔ 김광석다시그리기길 약 1.55km', () {
// 백엔드(PostGIS) 가 돌려준 거리 1552m 와 몇 m 안에서 맞는다
final m = distanceMeters(
35.87021,
128.59409,
35.8614155786,
128.6074607301,
);
expect(m, closeTo(1552, 10));
});
test('순서를 바꿔도 같다', () {
expect(
distanceMeters(35.8276, 128.6177, 35.83582, 128.57911),
closeTo(distanceMeters(35.83582, 128.57911, 35.8276, 128.6177), 1e-6),
);
});
});
group('walkMinutes', () {
test('시속 4km 로 올림한다', () {
expect(walkMinutes(650), 10);
expect(walkMinutes(67), 2);
expect(walkMinutes(66), 1);
});
test('아주 가까워도 최소 1분', () {
expect(walkMinutes(0), 1);
expect(walkMinutes(10), 1);
});
});
}
@@ -0,0 +1,177 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:wandergo/features/place/data/models/place_models.dart';
import 'package:wandergo/features/place/domain/place_facts.dart';
import 'package:wandergo/features/place/domain/place_search.dart';
import 'package:wandergo/shared/models/source_models.dart';
/// 개발 백엔드 실제 응답(2026-11-04)에서 가져온 모양
Map<String, dynamic> _listItem({
String category = 'cafe',
String source = 'store',
String petPolicy = 'unknown',
}) => {
'id': '5cb981e1-dfab-497d-b00d-ddac3c962f1e',
'name': '마실커피',
'branchName': '동성로점',
'category': category,
'source': source,
'address': '대구광역시 중구 중앙대로 380',
'lat': 35.8669069452959,
'lng': 128.593680108517,
'distanceM': 53,
'imageThumbUrl': null,
'imageLicense': null,
'hoursOrigin': 'unknown',
'openNow': null,
'petPolicy': petPolicy,
'petOrigin': 'unknown',
'setting': null,
'verifiedAt': null,
};
void main() {
group('PlaceItem.fromJson', () {
test('목록 한 건을 읽고 지점명을 이름에 붙인다', () {
final item = PlaceItem.fromJson(_listItem());
expect(item.displayName, '마실커피 동성로점');
expect(item.category, PlaceCategory.cafe);
expect(item.source, PlaceSource.store);
expect(item.distanceM, 53);
expect(item.openNow, isNull);
expect(item.hoursUnknown, isTrue);
expect(item.petPolicy, PetPolicy.unknown);
});
test('모르는 코드값은 null·unknown 으로 읽는다(앱이 죽지 않는다)', () {
final item = PlaceItem.fromJson(
_listItem(category: 'spaceport', source: 'x', petPolicy: 'maybe'),
);
expect(item.category, isNull);
expect(item.source, isNull);
expect(item.petPolicy, PetPolicy.unknown);
});
test('not_allowed 는 동반 불가, partial 은 동반 가능으로 본다', () {
expect(PetPolicy.parse('not_allowed'), PetPolicy.notAllowed);
expect(PetPolicy.notAllowed.isPetFriendly, isFalse);
expect(PetPolicy.parse('partial').isPetFriendly, isTrue);
});
});
group('PlaceDetail.fromJson', () {
test('상세 — 시간·반려동물·이용 안내·출처를 읽고 빈 값은 없음으로 본다', () {
final detail = PlaceDetail.fromJson({
..._listItem(category: 'attraction', source: 'tour'),
'branchName': null,
'name': '김광석다시그리기길',
'imageLicense': 'Type1',
'hoursOrigin': 'public',
'openNow': true,
'petPolicy': 'allowed',
'petOrigin': 'public',
'addressJibun': null,
'phone': null,
'homepage': 'https://tour.daegu.go.kr/index.do',
'overview': '',
'imageUrl': 'https://example.com/a.jpg',
'hours': {'text': '상시 개방', 'restText': '연중무휴', 'status': 'always_open'},
'pet': {'sizes': null, 'conditions': '전 견종 동반 가능 · 목줄 착용'},
'extra': {'parking': '불가능', 'infocenter': '053-627-1337', 'fee': ' '},
'curation': null,
'attribution': {'provider': '한국관광공사', 'asOf': '2026-06-26'},
});
expect(detail.item.displayName, '김광석다시그리기길');
expect(detail.item.imageLicense, ImageLicense.type1);
expect(detail.overview, isNull);
expect(detail.hours.status, HoursStatus.alwaysOpen);
expect(detail.hours.restText, '연중무휴');
expect(detail.pet.sizes, isNull);
expect(splitConditions(detail.pet.conditions), ['전 견종 동반 가능', '목줄 착용']);
expect(detail.extra, {'parking': '불가능', 'infocenter': '053-627-1337'});
expect(detail.curation, isNull);
expect(detail.attribution.provider, '한국관광공사');
});
test('운영자 확인 크기·추천 이유를 읽는다', () {
final detail = PlaceDetail.fromJson({
..._listItem(),
'hours': {'text': null, 'restText': null, 'status': 'unknown'},
'pet': {
'sizes': ['small', 'medium', 'huge'],
'conditions': null,
},
'extra': null,
'curation': {'reason': '골목 산책하기 좋아요.', 'officialUrl': null},
'attribution': {'provider': '소상공인시장진흥공단', 'asOf': '202609'},
});
expect(detail.pet.sizes, [PetSize.small, PetSize.medium]);
expect(detail.extra, isEmpty);
expect(detail.curation?.reason, '골목 산책하기 좋아요.');
});
});
group('PlacePage.fromJson', () {
test('items·pageInfo(total 포함)를 읽는다', () {
final page = PlacePage.fromJson({
'items': [_listItem()],
'pageInfo': {'hasNext': true, 'nextCursor': 'abc', 'total': 4500},
});
expect(page.items, hasLength(1));
expect(page.hasNext, isTrue);
expect(page.nextCursor, 'abc');
expect(page.total, 4500);
});
});
group('place_facts', () {
test('주차 — 확실할 때만 가능·불가, 애매하면 모름', () {
expect(parkingAvailable('가능'), isTrue);
expect(parkingAvailable('주차가능(승용차 20대)'), isTrue);
expect(parkingAvailable('불가능'), isFalse);
expect(parkingAvailable('없음'), isFalse);
expect(parkingAvailable('인근 공영주차장 이용'), isNull);
expect(parkingAvailable(null), isNull);
});
test('입장 무료 — 유료가 섞이면 무료로 보지 않는다', () {
expect(isFreeEntry('무료'), isTrue);
expect(isFreeEntry('무료(일부 체험 유료)'), isFalse);
expect(isFreeEntry('성인 3,000원'), isFalse);
expect(isFreeEntry(null), isFalse);
});
test('조건 — " · "·줄바꿈으로 나누고, 단어 안의 가운뎃점은 두다', () {
expect(splitConditions('목줄 착용 · 실내 전시·온실 시설은 동반 불가\n배변봉투 지참'), [
'목줄 착용',
'실내 전시·온실 시설은 동반 불가',
'배변봉투 지참',
]);
expect(splitConditions(null), isEmpty);
});
test('금지 조건 — 불가·금지·제한, "제한 없음"은 아니다', () {
expect(isRestriction('실내 동반 불가'), isTrue);
expect(isRestriction('맹견 출입 금지'), isTrue);
expect(isRestriction('15kg 이상 제한'), isTrue);
expect(isRestriction('크기 제한 없음'), isFalse);
expect(isRestriction('수성못 산책로 이용'), isFalse);
});
});
group('PlaceQuery', () {
test('검색어 앞뒤 공백은 같은 조건으로 본다', () {
expect(const PlaceQuery(text: ' 김광석 '), const PlaceQuery(text: '김광석'));
expect(const PlaceQuery(text: ' ').queryOrNull, isNull);
});
test('탭은 백엔드 분류 여러 개로 바뀐다', () {
expect(PlaceTab.food.categories, [
PlaceCategory.food,
PlaceCategory.cafe,
PlaceCategory.bar,
]);
expect(PlaceTab.all.categories, isEmpty);
expect(PlaceTab.parse('nope'), PlaceTab.all);
});
});
}
+210
View File
@@ -10,6 +10,8 @@ import 'package:wandergo/features/event/data/models/event_models.dart';
import 'package:wandergo/features/event/data/repositories/event_repository.dart';
import 'package:wandergo/features/location/data/location_service.dart';
import 'package:wandergo/features/location/domain/base_location.dart';
import 'package:wandergo/features/place/data/models/place_models.dart';
import 'package:wandergo/features/place/data/repositories/place_repository.dart';
/// 2026-11-04(수) 한국 정오 — 이번 주말은 11-07~11-08
final DateTime _now = DateTime.utc(2026, 11, 4, 3);
@@ -60,6 +62,97 @@ class _FakeEventRepository implements EventRepository {
);
}
PlaceItem _place(
String id,
String name, {
String? branchName,
PlaceCategory category = PlaceCategory.nature,
PlaceSource source = PlaceSource.tour,
PetPolicy petPolicy = PetPolicy.unknown,
ValueOrigin hoursOrigin = ValueOrigin.public,
bool? openNow,
double distanceM = 260,
}) => PlaceItem(
id: id,
name: name,
branchName: branchName,
category: category,
source: source,
address: '대구광역시 중구 경상감영길 99',
lat: 35.8716,
lng: 128.5925,
distanceM: distanceM,
hoursOrigin: hoursOrigin,
openNow: openNow,
petPolicy: petPolicy,
petOrigin: petPolicy == PetPolicy.unknown
? ValueOrigin.unknown
: ValueOrigin.public,
);
final PlaceItem _petPark = _place(
'p-pet',
'경상감영공원',
petPolicy: PetPolicy.allowed,
openNow: true,
);
final PlaceItem _storeCafe = _place(
'p-cafe',
'마실커피',
branchName: '동성로점',
category: PlaceCategory.cafe,
source: PlaceSource.store,
hoursOrigin: ValueOrigin.unknown,
distanceM: 53,
);
/// 가짜 장소 API — 받은 조건을 기록하고, 반려동물 조건이면 [petPlaces] 를 준다
class _FakePlaceRepository implements PlaceRepository {
_FakePlaceRepository({this.places = const [], this.petPlaces = const []});
final List<PlaceItem> places;
final List<PlaceItem> petPlaces;
final List<({String? query, List<PlaceCategory> categories, bool petOnly})>
calls = [];
@override
Future<PlacePage> search({
String? query,
List<PlaceCategory> categories = const [],
bool petOnly = false,
GeoPoint? near,
int? radiusM,
int limit = 20,
String? cursor,
}) async {
calls.add((query: query, categories: categories, petOnly: petOnly));
final items = petOnly ? petPlaces : places;
return PlacePage(items: items, hasNext: false, total: items.length);
}
@override
Future<PlaceDetail> getPlace(String id) async {
if (id == _petPark.id) {
return PlaceDetail(
item: _petPark,
attribution: const Attribution(provider: '한국관광공사', asOf: '2026-06-26'),
overview: '대구 도심 한가운데의 공원',
hours: const PlaceHours(text: '상시 개방', status: HoursStatus.alwaysOpen),
pet: const PlacePet(conditions: '목줄 착용 · 실내 시설은 동반 불가'),
extra: const {'parking': '불가능', 'infocenter': '053-000-1111'},
);
}
return PlaceDetail(
item: places.firstWhere((p) => p.id == id),
attribution: const Attribution(
provider: '소상공인시장진흥공단 상가(상권)정보',
asOf: '202609',
),
);
}
}
class _FakeLocationService implements LocationService {
const _FakeLocationService({this.granted = true});
@@ -77,6 +170,7 @@ Future<_FakeEventRepository> _pumpApp(
WidgetTester tester, {
Map<String, Object> prefs = const {},
_FakeEventRepository? repo,
_FakePlaceRepository? places,
bool locationGranted = true,
}) async {
SharedPreferences.setMockInitialValues({'app_locale': 'ko', ...prefs});
@@ -87,6 +181,9 @@ Future<_FakeEventRepository> _pumpApp(
overrides: [
sharedPreferencesProvider.overrideWithValue(preferences),
eventRepositoryProvider.overrideWithValue(events),
placeRepositoryProvider.overrideWithValue(
places ?? _FakePlaceRepository(),
),
locationServiceProvider.overrideWithValue(
_FakeLocationService(granted: locationGranted),
),
@@ -106,6 +203,12 @@ Finder _text(String text) => find.byWidgetPredicate(
(w.data ?? w.textSpan?.toPlainText() ?? '').replaceAll('⁠', '') == text,
);
/// 긴 화면(홈 아래 칸·상세 전체)을 스크롤 없이 확인하려고 화면을 길게 늘린다
Future<void> _useTallScreen(WidgetTester tester) async {
await tester.binding.setSurfaceSize(const Size(800, 2600));
addTearDown(() => tester.binding.setSurfaceSize(null));
}
void main() {
testWidgets('첫 실행: 동네를 고르면 홈으로 가고, 그 동네 기준 이번 주말 행사를 보인다', (tester) async {
final repo = await _pumpApp(
@@ -153,6 +256,7 @@ void main() {
});
testWidgets('행사를 누르면 상세에 시간·전화·출처가 보인다', (tester) async {
await _useTallScreen(tester);
await _pumpApp(
tester,
prefs: {'onboarding_done': true},
@@ -179,4 +283,110 @@ void main() {
expect(find.text('Home'), findsOneWidget);
expect(find.text('My courses'), findsOneWidget);
});
testWidgets('홈 반려동물 칸의 전체 보기는 반려견 동반 검색을 열고, 장소를 누르면 동반 조건이 보인다', (
tester,
) async {
await _useTallScreen(tester);
final places = _FakePlaceRepository(petPlaces: [_petPark]);
await _pumpApp(tester, prefs: {'onboarding_done': true}, places: places);
expect(find.text('가까운 반려동물 동반 장소'), findsOneWidget);
expect(find.text('자연·공원 · 도보 4분'), findsOneWidget);
expect(places.calls.single.petOnly, isTrue);
await tester.tap(find.text('전체 보기'));
await tester.pumpAndSettle();
expect(places.calls.last.petOnly, isTrue);
expect(find.text('1곳'), findsOneWidget);
expect(find.text('경상감영공원'), findsOneWidget);
await tester.tap(find.text('경상감영공원'));
await tester.pumpAndSettle();
expect(find.text('반려동물 동반 정보'), findsOneWidget);
expect(_text('목줄 착용'), findsOneWidget);
expect(_text('실내 시설은 동반 불가'), findsOneWidget);
// 상시 개방은 한눈에 보기에 오르고, 주차 원문이 불가능이면 오르지 않는다
expect(find.text('상시 개방'), findsWidgets);
expect(find.text('주차 가능'), findsNothing);
expect(find.text('출처: 한국관광공사 · 2026-06-26 기준'), findsOneWidget);
});
testWidgets('검색: 입력을 멈추면 검색어로 찾고, 분류 탭은 백엔드 분류로 바꿔 보낸다', (tester) async {
final places = _FakePlaceRepository(places: [_storeCafe]);
await _pumpApp(tester, prefs: {'onboarding_done': true}, places: places);
await tester.tap(find.text('장소 이름으로 검색'));
await tester.pumpAndSettle();
expect(places.calls.last.query, isNull);
await tester.enterText(find.byType(TextField), '마실');
await tester.pump(const Duration(milliseconds: 400));
await tester.pumpAndSettle();
expect(places.calls.last.query, '마실');
await tester.tap(find.text('맛집·카페'));
await tester.pumpAndSettle();
expect(places.calls.last.query, '마실');
expect(places.calls.last.categories, [
PlaceCategory.food,
PlaceCategory.cafe,
PlaceCategory.bar,
]);
expect(find.text('마실커피 동성로점'), findsOneWidget);
expect(find.text('영업시간 미확인'), findsOneWidget);
});
testWidgets('조건에 맞는 곳이 없으면 반려견 동반을 빼고 다시 찾을 수 있다', (tester) async {
final places = _FakePlaceRepository(places: [_storeCafe]);
await _pumpApp(tester, prefs: {'onboarding_done': true}, places: places);
await tester.tap(find.text('반려동물'));
await tester.pumpAndSettle();
expect(_text('조건에 맞는 곳이 없어요'), findsOneWidget);
await tester.tap(find.text('반려견 동반 빼고 보기'));
await tester.pumpAndSettle();
expect(places.calls.last.petOnly, isFalse);
expect(find.text('마실커피 동성로점'), findsOneWidget);
});
testWidgets('장소 상세: 영업시간을 모르면 미확인으로, 상가정보 출처는 기준년월로 보인다', (tester) async {
await _useTallScreen(tester);
final places = _FakePlaceRepository(places: [_storeCafe]);
await _pumpApp(tester, prefs: {'onboarding_done': true}, places: places);
await tester.tap(find.text('장소 이름으로 검색'));
await tester.pumpAndSettle();
await tester.tap(find.text('마실커피 동성로점'));
await tester.pumpAndSettle();
expect(_text('영업시간 미확인 · 방문 전 확인'), findsOneWidget);
expect(find.textContaining('대구 도심에서 도보'), findsOneWidget);
expect(find.text('출처: 소상공인시장진흥공단 상가(상권)정보 · 2026-09 기준'), findsOneWidget);
// 반려동물 정보가 없으면 칸을 숨기고, 1330 은 영어 화면에만 보인다
expect(find.text('반려동물 동반 정보'), findsNothing);
expect(find.text('관광통역안내 1330'), findsNothing);
});
testWidgets('영어 화면의 장소 상세에는 1330 관광통역안내가 보인다', (tester) async {
await _useTallScreen(tester);
final places = _FakePlaceRepository(places: [_storeCafe]);
await _pumpApp(
tester,
prefs: {'onboarding_done': true, 'app_locale': 'en'},
places: places,
);
await tester.tap(find.text('Search places by name'));
await tester.pumpAndSettle();
await tester.tap(find.text('마실커피 동성로점'));
await tester.pumpAndSettle();
expect(find.text('Need help? Call 1330'), findsOneWidget);
expect(_text('Hours not confirmed · check before you go'), findsOneWidget);
});
}