generated from ttipo/project-base
feat(flutter): 앱 뼈대와 홈 이번 주말 축제·행사 화면
데이터 기반(장소·행사 조회 API)이 생겨 앱 화면을 붙이기 시작한다. 디자인 캔버스의 1~7번(시작·둘러보기) 중 지금 API 로 바로 되는 것부터 만들었다. - 앱 뼈대: 디자인 캔버스의 색·글자 크기·선 아이콘을 옮긴 테마, go_router 하단 탭 (홈·지도·내 코스·내 정보 — 지도·내 코스·내 정보는 자리만), 한/영 문구(arb, 4개 언어 전제) - 첫 실행: 현재 위치를 쓰거나 대구 동네(도심·수성못·앞산 카페거리)를 고른다. 위치를 못 쓰면 대구 도심을 기준으로 시작하고 알린다. 좌표는 저장하지 않는다 - 홈 '이번 주말 축제·행사': 기준 위치 반경 120km 거리순, 비면 다가오는 30일 (2026-11-03 합의) - 행사 전체 목록(커서 페이지)·상세(시간·요금·예매·전화·소개·출처) - 한국어 문단은 단어 단위 줄바꿈(keepAll) — Flutter 기본은 글자 단위로 끊는다 - 공공누리 Type3 사진은 자르지 않고, 꼬리말에 관광공사·KOPIS·문화포털 출처를 적는다 - 한/영 생성물(lib/l10n/generated)은 flutter pub get 이 만들므로 git 에서 뺀다 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:intl/date_symbol_data_local.dart';
|
||||
import 'package:wandergo/core/utils/format.dart';
|
||||
|
||||
void main() {
|
||||
// 앱에서는 flutter_localizations 가 지금 언어의 날짜 표기 데이터를 준비한다
|
||||
setUpAll(() => initializeDateFormatting('en'));
|
||||
|
||||
group('formatDistance', () {
|
||||
test('1km 미만은 m, 100km 미만은 소수 한 자리 km, 그보다 멀면 정수 km', () {
|
||||
expect(formatDistance(850), '850m');
|
||||
expect(formatDistance(999.6), '1.0km');
|
||||
expect(formatDistance(1240), '1.2km');
|
||||
expect(formatDistance(85300), '85.3km');
|
||||
expect(formatDistance(123456), '123km');
|
||||
});
|
||||
});
|
||||
|
||||
group('formatDateRange', () {
|
||||
test('한국어는 월.일, 하루짜리는 한 날짜만', () {
|
||||
expect(formatDateRange('2026-10-31', '2026-11-02'), '10.31–11.2');
|
||||
expect(formatDateRange('2026-11-08', '2026-11-08'), '11.8');
|
||||
});
|
||||
|
||||
test('영어는 그 언어의 월·일 표기', () {
|
||||
expect(
|
||||
formatDateRange('2026-10-31', '2026-11-02', localeName: 'en'),
|
||||
'Oct 31 – Nov 2',
|
||||
);
|
||||
});
|
||||
|
||||
test('형식이 틀리면 원문을 그대로 보인다', () {
|
||||
expect(
|
||||
formatDateRange('2026.10.31', '2026.11.02'),
|
||||
'2026.10.31–2026.11.02',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('shortRegion', () {
|
||||
test('시·도 다음 단어(구·군·시)를 꺼낸다', () {
|
||||
expect(shortRegion('대구광역시 수성구 두산동 512'), '수성구');
|
||||
expect(shortRegion('경상북도 포항시 북구 해안로 1'), '포항시');
|
||||
expect(shortRegion('경북 경주시 첨성로 1'), '경주시');
|
||||
});
|
||||
|
||||
test('시·도로 시작하지 않거나 비어 있으면 null', () {
|
||||
expect(shortRegion(null), isNull);
|
||||
expect(shortRegion('수성못 일원'), isNull);
|
||||
expect(shortRegion('대구광역시'), isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:wandergo/core/utils/keep_all.dart';
|
||||
|
||||
void main() {
|
||||
group('keepAll', () {
|
||||
test('단어 안의 글자 사이에만 줄바꿈 금지 글자를 넣는다', () {
|
||||
expect(keepAll('어디 갈지'), '어디 갈지');
|
||||
});
|
||||
|
||||
test('공백·줄바꿈은 그대로 두고, 보이는 글자는 바뀌지 않는다', () {
|
||||
const text = '어디 갈지,\n같이 짜 볼까요?';
|
||||
final result = keepAll(text);
|
||||
expect(result.replaceAll('', ''), text);
|
||||
expect(result.split('\n'), hasLength(2));
|
||||
});
|
||||
|
||||
test('빈 문자열·한 글자는 그대로', () {
|
||||
expect(keepAll(''), '');
|
||||
expect(keepAll('홈'), '홈');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:wandergo/core/utils/weekend.dart';
|
||||
|
||||
/// 한국 시각으로 만든 순간(UTC 로 바꿔 넘긴다)
|
||||
DateTime kst(int y, int m, int d, [int h = 12]) =>
|
||||
DateTime.utc(y, m, d, h).subtract(const Duration(hours: 9));
|
||||
|
||||
void main() {
|
||||
group('weekendRange', () {
|
||||
test('평일이면 다가오는 토·일', () {
|
||||
// 2026-11-04 수요일
|
||||
expect(weekendRange(kst(2026, 11, 4)), (
|
||||
from: '2026-11-07',
|
||||
to: '2026-11-08',
|
||||
));
|
||||
});
|
||||
|
||||
test('토요일이면 오늘·내일, 일요일이면 오늘 하루', () {
|
||||
expect(weekendRange(kst(2026, 11, 7)), (
|
||||
from: '2026-11-07',
|
||||
to: '2026-11-08',
|
||||
));
|
||||
expect(weekendRange(kst(2026, 11, 8)), (
|
||||
from: '2026-11-08',
|
||||
to: '2026-11-08',
|
||||
));
|
||||
});
|
||||
|
||||
test('기기 시간대와 상관없이 한국 날짜로 계산한다', () {
|
||||
// UTC 로는 아직 토요일 15:30 이지만 한국은 일요일 00:30 — 일요일 하루만
|
||||
expect(weekendRange(DateTime.utc(2026, 11, 7, 15, 30)), (
|
||||
from: '2026-11-08',
|
||||
to: '2026-11-08',
|
||||
));
|
||||
});
|
||||
|
||||
test('월을 넘어가는 주말', () {
|
||||
// 2026-10-30 금요일 → 10-31(토)·11-01(일)
|
||||
expect(weekendRange(kst(2026, 10, 30)), (
|
||||
from: '2026-10-31',
|
||||
to: '2026-11-01',
|
||||
));
|
||||
});
|
||||
});
|
||||
|
||||
group('upcomingRange', () {
|
||||
test('오늘을 포함해 N일', () {
|
||||
expect(upcomingRange(kst(2026, 11, 4), days: 30), (
|
||||
from: '2026-11-04',
|
||||
to: '2026-12-03',
|
||||
));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:tma_app/core/api/api_exception.dart';
|
||||
import 'package:tma_app/core/api/error_lexicon.dart';
|
||||
import 'package:wandergo/core/api/api_exception.dart';
|
||||
import 'package:wandergo/core/api/error_lexicon.dart';
|
||||
|
||||
void main() {
|
||||
group('ErrorLexicon', () {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:wandergo/features/event/data/models/event_models.dart';
|
||||
import 'package:wandergo/features/location/domain/base_location.dart';
|
||||
|
||||
void main() {
|
||||
group('EventPage.fromJson', () {
|
||||
test('백엔드 목록 응답(data)을 읽는다 — 숫자는 정수·실수 모두 받는다', () {
|
||||
final page = EventPage.fromJson({
|
||||
'items': [
|
||||
{
|
||||
'id': 'a1',
|
||||
'name': '수성빛예술제',
|
||||
'kind': 'festival',
|
||||
'startDate': '2026-11-27',
|
||||
'endDate': '2026-12-06',
|
||||
'genre': null,
|
||||
'ongoing': false,
|
||||
'address': '대구광역시 수성구 두산동',
|
||||
'eventPlace': '수성못 일원',
|
||||
'lat': 35.8282,
|
||||
'lng': 128,
|
||||
'distanceM': 1240,
|
||||
'feeText': '무료',
|
||||
'imageThumbUrl': null,
|
||||
'imageLicense': 'Type3',
|
||||
},
|
||||
],
|
||||
'pageInfo': {'hasNext': true, 'nextCursor': 'abc', 'total': null},
|
||||
});
|
||||
|
||||
expect(page.hasNext, isTrue);
|
||||
expect(page.nextCursor, 'abc');
|
||||
final item = page.items.single;
|
||||
expect(item.kind, EventKind.festival);
|
||||
expect(item.lng, 128.0);
|
||||
expect(item.distanceM, 1240.0);
|
||||
expect(item.imageLicense, ImageLicense.type3);
|
||||
});
|
||||
|
||||
test('모르는 분류·사진 유형은 null 로 둔다(앱이 죽지 않게)', () {
|
||||
final item = EventItem.fromJson({
|
||||
'id': 'a2',
|
||||
'name': '새 분류',
|
||||
'kind': 'workshop',
|
||||
'startDate': '2026-11-07',
|
||||
'endDate': '2026-11-07',
|
||||
'ongoing': true,
|
||||
'imageLicense': 'Type9',
|
||||
});
|
||||
expect(item.kind, isNull);
|
||||
expect(item.imageLicense, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('EventDetail.fromJson', () {
|
||||
test('상세 필드와 출처를 읽는다', () {
|
||||
final detail = EventDetail.fromJson({
|
||||
'id': 'b1',
|
||||
'name': '라보엠',
|
||||
'kind': 'performance',
|
||||
'startDate': '2026-11-07',
|
||||
'endDate': '2026-11-07',
|
||||
'genre': '오페라',
|
||||
'ongoing': false,
|
||||
'overview': '소개',
|
||||
'playtimeText': '토요일(19:30)',
|
||||
'homepage': null,
|
||||
'phone': '053-000-0000',
|
||||
'bookingUrl': 'https://tickets.test/1',
|
||||
'imageUrl': 'https://kopis.or.kr/poster.jpg',
|
||||
'attribution': {'provider': 'KOPIS', 'asOf': null},
|
||||
});
|
||||
expect(detail.item.genre, '오페라');
|
||||
expect(detail.bookingUrl, 'https://tickets.test/1');
|
||||
expect(detail.attribution.provider, 'KOPIS');
|
||||
});
|
||||
});
|
||||
|
||||
group('BaseLocationChoice', () {
|
||||
test('저장 문자열로 오가도 같은 값이다', () {
|
||||
const choices = [
|
||||
BaseLocationChoice.current(),
|
||||
BaseLocationChoice.neighborhood(Neighborhood.suseongmot),
|
||||
];
|
||||
for (final c in choices) {
|
||||
expect(BaseLocationChoice.fromStorageKey(c.storageKey), c);
|
||||
}
|
||||
expect(BaseLocationChoice.fromStorageKey('seoul'), isNull);
|
||||
expect(BaseLocationChoice.fromStorageKey(null), isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,15 +1,182 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:tma_app/main.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:wandergo/app.dart';
|
||||
import 'package:wandergo/core/clock.dart';
|
||||
import 'package:wandergo/core/storage/preferences.dart';
|
||||
import 'package:wandergo/core/utils/weekend.dart';
|
||||
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';
|
||||
|
||||
/// 2026-11-04(수) 한국 정오 — 이번 주말은 11-07~11-08
|
||||
final DateTime _now = DateTime.utc(2026, 11, 4, 3);
|
||||
|
||||
EventItem _event(
|
||||
String id,
|
||||
String name, {
|
||||
EventKind kind = EventKind.festival,
|
||||
}) => EventItem(
|
||||
id: id,
|
||||
name: name,
|
||||
kind: kind,
|
||||
startDate: '2026-11-07',
|
||||
endDate: '2026-11-08',
|
||||
ongoing: false,
|
||||
address: '대구광역시 수성구 두산동',
|
||||
distanceM: 1240,
|
||||
);
|
||||
|
||||
/// 가짜 행사 API — 받은 조건을 기록하고, 주말·다가오는 기간에 따라 다른 목록을 준다
|
||||
class _FakeEventRepository implements EventRepository {
|
||||
_FakeEventRepository({this.weekend = const [], this.upcoming = const []});
|
||||
|
||||
final List<EventItem> weekend;
|
||||
final List<EventItem> upcoming;
|
||||
final List<({DateRange range, GeoPoint? near})> calls = [];
|
||||
|
||||
@override
|
||||
Future<EventPage> search({
|
||||
required DateRange range,
|
||||
GeoPoint? near,
|
||||
int? radiusM,
|
||||
int limit = 20,
|
||||
String? cursor,
|
||||
}) async {
|
||||
calls.add((range: range, near: near));
|
||||
final isWeekend = range == weekendRange(_now);
|
||||
return EventPage(items: isWeekend ? weekend : upcoming, hasNext: false);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<EventDetail> getEvent(String id) async => EventDetail(
|
||||
item: _event(id, '수성빛예술제'),
|
||||
attribution: const Attribution(provider: '한국관광공사', asOf: '2026-10-15'),
|
||||
overview: '수성못에서 열리는 빛 축제',
|
||||
playtimeText: '17:00~22:00',
|
||||
phone: '053-000-0000',
|
||||
);
|
||||
}
|
||||
|
||||
class _FakeLocationService implements LocationService {
|
||||
const _FakeLocationService({this.granted = true});
|
||||
|
||||
final bool granted;
|
||||
|
||||
@override
|
||||
Future<bool> requestPermission() async => granted;
|
||||
|
||||
@override
|
||||
Future<GeoPoint?> currentPoint() async =>
|
||||
granted ? (lat: 35.85, lng: 128.6) : null;
|
||||
}
|
||||
|
||||
Future<_FakeEventRepository> _pumpApp(
|
||||
WidgetTester tester, {
|
||||
Map<String, Object> prefs = const {},
|
||||
_FakeEventRepository? repo,
|
||||
bool locationGranted = true,
|
||||
}) async {
|
||||
SharedPreferences.setMockInitialValues({'app_locale': 'ko', ...prefs});
|
||||
final preferences = await SharedPreferences.getInstance();
|
||||
final events = repo ?? _FakeEventRepository();
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
sharedPreferencesProvider.overrideWithValue(preferences),
|
||||
eventRepositoryProvider.overrideWithValue(events),
|
||||
locationServiceProvider.overrideWithValue(
|
||||
_FakeLocationService(granted: locationGranted),
|
||||
),
|
||||
clockProvider.overrideWithValue(() => _now),
|
||||
],
|
||||
child: const WanderGoApp(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
return events;
|
||||
}
|
||||
|
||||
/// 화면 글자는 단어 단위 줄바꿈용 보이지 않는 글자를 품고 있어, 그 글자를 빼고 찾는다
|
||||
Finder _text(String text) => find.byWidgetPredicate(
|
||||
(w) =>
|
||||
w is Text &&
|
||||
(w.data ?? w.textSpan?.toPlainText() ?? '').replaceAll('', '') == text,
|
||||
);
|
||||
|
||||
void main() {
|
||||
testWidgets('앱이 정상적으로 렌더링된다', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
const ProviderScope(child: MyApp()),
|
||||
testWidgets('첫 실행: 동네를 고르면 홈으로 가고, 그 동네 기준 이번 주말 행사를 보인다', (tester) async {
|
||||
final repo = await _pumpApp(
|
||||
tester,
|
||||
repo: _FakeEventRepository(weekend: [_event('e1', '수성빛예술제')]),
|
||||
);
|
||||
|
||||
expect(find.byType(MaterialApp), findsOneWidget);
|
||||
expect(find.byType(Scaffold), findsOneWidget);
|
||||
expect(_text('어디 갈지,\n같이 짜 볼까요?'), findsOneWidget);
|
||||
await tester.tap(find.text('지역 직접 고르기'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('수성못'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(_text('이번 주말, 어디 갈까요?'), findsOneWidget);
|
||||
expect(find.text('수성못'), findsOneWidget);
|
||||
expect(find.text('이번 주말 축제·행사'), findsOneWidget);
|
||||
expect(find.text('수성빛예술제'), findsOneWidget);
|
||||
expect(find.text('수성구 · 1.2km'), findsOneWidget);
|
||||
expect(repo.calls.single.near, Neighborhood.suseongmot.point);
|
||||
});
|
||||
|
||||
testWidgets('위치 권한을 거부하면 대구 도심을 기준으로 시작하고 안내한다', (tester) async {
|
||||
await _pumpApp(tester, locationGranted: false);
|
||||
|
||||
await tester.tap(find.text('현재 위치 사용하기'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('대구 도심'), findsOneWidget);
|
||||
expect(_text('현재 위치를 가져오지 못해 대구 도심을 기준으로 보여 드려요.'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('주말 행사가 없으면 다가오는 축제·행사(30일)로 바꿔 보인다', (tester) async {
|
||||
final repo = await _pumpApp(
|
||||
tester,
|
||||
prefs: {'onboarding_done': true},
|
||||
repo: _FakeEventRepository(upcoming: [_event('e2', '대구 재즈 페스티벌')]),
|
||||
);
|
||||
|
||||
expect(find.text('다가오는 축제·행사'), findsOneWidget);
|
||||
expect(find.text('대구 재즈 페스티벌'), findsOneWidget);
|
||||
expect(repo.calls.map((c) => c.range).toList(), [
|
||||
(from: '2026-11-07', to: '2026-11-08'),
|
||||
(from: '2026-11-04', to: '2026-12-03'),
|
||||
]);
|
||||
});
|
||||
|
||||
testWidgets('행사를 누르면 상세에 시간·전화·출처가 보인다', (tester) async {
|
||||
await _pumpApp(
|
||||
tester,
|
||||
prefs: {'onboarding_done': true},
|
||||
repo: _FakeEventRepository(weekend: [_event('e1', '수성빛예술제')]),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('수성빛예술제'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(BackButton), findsOneWidget);
|
||||
expect(_text('17:00~22:00'), findsOneWidget);
|
||||
expect(find.textContaining('053-000-0000'), findsOneWidget);
|
||||
expect(find.text('출처: 한국관광공사 · 2026-10-15 기준'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('언어를 English 로 바꾸면 탭 이름이 영어로 바뀐다', (tester) async {
|
||||
await _pumpApp(tester, prefs: {'onboarding_done': true});
|
||||
|
||||
await tester.tap(find.text('KO'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('English'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Home'), findsOneWidget);
|
||||
expect(find.text('My courses'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user