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