generated from ttipo/project-base
feat(flutter): AI 코스 입력·만드는 중·결과 화면(8–10) 추가
백엔드 AI 코스 API(4–6단계)를 앱에서 쓸 수 있게 화면 8–10 을 붙였다(설계 ai-course §14 7단계). - 기반: 첫 실행 때 만든 기기 ID 를 안전 저장소에 두고 X-Device-Id 로 보낸다(비로그인 하루 횟수 기준). 앱 언어를 Accept-Language 로 보내 AI 문구 언어를 맞춘다 - 홈 'AI에게 코스 맡기기' → 입력(8): 문장을 1초 멈추면 조건을 해석해 줄마다 채우고 고칠 수 있다. 서버와 같은 조건 규칙을 앱에서도 먼저 본다. 하루 횟수를 다 썼으면 만들지 않고 안내한다 - 만드는 중(9): 1.5초마다 진행을 조회한다. 나가면 취소하고(차감 없음), 만들던 생성 id 를 남겨 앱을 다시 켜거나 AI_004 를 받으면 이어 본다 - 결과(10): 사실 값 · 경고 · 이동 · 바꾸기 후보(한 번에 한 곳, 횟수 안 듦) · 평가 · 다시 만들기(확인 뒤 차감) · 출처와 AI 생성 표시. 요청 칸과 다시 만들기는 이 기기에서 입력한 생성일 때만 보인다 - 디자인 캔버스와 맞춤: 결과의 코스 제목은 빼고 'AI 추천 코스'와 요약으로 시작한다(사용자 결정). 마스코트 그림을 홈 · 입력 · 만드는 중 · 결과 · 횟수 소진에 넣었다 - 화면 전체를 못 불러왔을 때의 공통 오류 화면(그림 · 다시 시도 · 오류 코드와 추적 ID)을 만들어 만드는 중 화면에 쓴다 코스로 저장 · 지도 미리보기 · 홈의 비 소식 줄 · 횟수 소진의 로그인 버튼은 그 기능이 생기면 붙인다. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:wandergo/core/device/device_id.dart';
|
||||
import 'package:wandergo/core/utils/uuid.dart';
|
||||
|
||||
final RegExp _uuidV4 = RegExp(
|
||||
r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$',
|
||||
);
|
||||
|
||||
void main() {
|
||||
group('uuidV4', () {
|
||||
test('버전 4 · RFC 4122 변형 모양이다', () {
|
||||
for (var seed = 0; seed < 50; seed++) {
|
||||
expect(uuidV4(Random(seed)), matches(_uuidV4));
|
||||
}
|
||||
});
|
||||
|
||||
test('같은 씨앗이면 같은 값, 다른 씨앗이면 다른 값', () {
|
||||
expect(uuidV4(Random(1)), uuidV4(Random(1)));
|
||||
expect(uuidV4(Random(1)), isNot(uuidV4(Random(2))));
|
||||
});
|
||||
});
|
||||
|
||||
group('loadOrCreateDeviceId', () {
|
||||
test('저장된 값이 있으면 그대로 쓰고 새로 만들지 않는다', () async {
|
||||
final written = <String>[];
|
||||
final id = await loadOrCreateDeviceId(
|
||||
read: () async => ' saved-id ',
|
||||
write: (id) async => written.add(id),
|
||||
create: () => fail('만들면 안 된다'),
|
||||
);
|
||||
expect(id, 'saved-id');
|
||||
expect(written, isEmpty);
|
||||
});
|
||||
|
||||
test('없거나 비어 있으면 만들어 저장한다', () async {
|
||||
for (final saved in [null, '', ' ']) {
|
||||
final written = <String>[];
|
||||
final id = await loadOrCreateDeviceId(
|
||||
read: () async => saved,
|
||||
write: (id) async => written.add(id),
|
||||
create: () => 'new-id',
|
||||
);
|
||||
expect(id, 'new-id');
|
||||
expect(written, ['new-id']);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:intl/date_symbol_data_local.dart';
|
||||
import 'package:wandergo/features/ai_course/data/models/course_conditions.dart';
|
||||
import 'package:wandergo/features/ai_course/data/models/course_generation.dart';
|
||||
import 'package:wandergo/features/ai_course/domain/ai_course_draft.dart';
|
||||
import 'package:wandergo/features/ai_course/domain/condition_rules.dart';
|
||||
import 'package:wandergo/features/ai_course/domain/generation_steps.dart';
|
||||
import 'package:wandergo/features/location/domain/base_location.dart';
|
||||
|
||||
/// 2026-11-12(목) 한국 12:00
|
||||
final DateTime _now = DateTime.utc(2026, 11, 12, 3);
|
||||
|
||||
CourseConditions _conditions({
|
||||
String date = '2026-11-14',
|
||||
String start = '14:00',
|
||||
String end = '20:00',
|
||||
int party = 2,
|
||||
CourseArea area = const NeighborhoodArea(Neighborhood.suseongmot),
|
||||
}) => CourseConditions(
|
||||
area: area,
|
||||
date: date,
|
||||
start: start,
|
||||
end: end,
|
||||
party: party,
|
||||
pet: null,
|
||||
mobility: Mobility.walk,
|
||||
indoorIfRain: true,
|
||||
);
|
||||
|
||||
void main() {
|
||||
// 앱에서는 flutter_localizations 가 지금 언어의 날짜 표기 데이터를 준비한다
|
||||
setUpAll(() => initializeDateFormatting('en'));
|
||||
|
||||
group('시각 · 날짜', () {
|
||||
test("'HH:mm' 을 분으로 읽고 다시 쓴다('24:00' 까지)", () {
|
||||
expect(parseHhmm('14:30'), 870);
|
||||
expect(parseHhmm('24:00'), 1440);
|
||||
expect(parseHhmm('24:30'), isNull);
|
||||
expect(parseHhmm('9:00'), isNull);
|
||||
expect(formatHhmm(870), '14:30');
|
||||
expect(formatHhmm(1440), '24:00');
|
||||
});
|
||||
|
||||
test('한국 날짜와 그날 지난 분 — 기기 시간대와 상관없다', () {
|
||||
expect(kstClock(_now), (date: '2026-11-12', minutes: 720));
|
||||
expect(kstClock(DateTime.utc(2026, 11, 12, 16)).date, '2026-11-13');
|
||||
expect(formatKstTime(DateTime.utc(2026, 11, 14, 5, 10)), '14:10');
|
||||
});
|
||||
|
||||
test('조건 날짜 표시 — 한국어 "11.14(토)", 영어 "Sat, Nov 14"', () {
|
||||
expect(formatCourseDate('2026-11-14', localeName: 'ko'), '11.14(토)');
|
||||
expect(formatCourseDate('2026-11-14', localeName: 'en'), 'Sat, Nov 14');
|
||||
expect(formatWeekday('2026-11-14', localeName: 'ko'), '토요일');
|
||||
expect(formatWeekday('2026-11-14', localeName: 'en'), 'Saturday');
|
||||
});
|
||||
});
|
||||
|
||||
group('conditionsProblem — 백엔드와 같은 기준', () {
|
||||
test('오늘~30일 · 2~12시간 · 오늘이면 아직 끝나지 않은 시간이면 통과', () {
|
||||
expect(conditionsProblem(_conditions(), _now), isNull);
|
||||
expect(
|
||||
conditionsProblem(
|
||||
_conditions(date: '2026-11-12', start: '11:00', end: '14:00'),
|
||||
_now,
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
expect(conditionsProblem(_conditions(date: '2026-12-12'), _now), isNull);
|
||||
});
|
||||
|
||||
test('날짜 · 시간 문제를 나눈다', () {
|
||||
expect(
|
||||
conditionsProblem(_conditions(date: '2026-11-11'), _now),
|
||||
ConditionProblem.pastDate,
|
||||
);
|
||||
expect(
|
||||
conditionsProblem(_conditions(date: '2026-12-13'), _now),
|
||||
ConditionProblem.dateOutOfRange,
|
||||
);
|
||||
expect(
|
||||
conditionsProblem(_conditions(start: '14:00', end: '15:30'), _now),
|
||||
ConditionProblem.tooShort,
|
||||
);
|
||||
expect(
|
||||
conditionsProblem(_conditions(start: '20:00', end: '14:00'), _now),
|
||||
ConditionProblem.tooShort,
|
||||
);
|
||||
expect(
|
||||
conditionsProblem(_conditions(start: '09:00', end: '21:30'), _now),
|
||||
ConditionProblem.tooLong,
|
||||
);
|
||||
expect(
|
||||
conditionsProblem(
|
||||
_conditions(date: '2026-11-12', start: '09:00', end: '12:00'),
|
||||
_now,
|
||||
),
|
||||
ConditionProblem.pastTime,
|
||||
);
|
||||
expect(
|
||||
conditionsProblem(_conditions(start: '2pm'), _now),
|
||||
ConditionProblem.invalidTime,
|
||||
);
|
||||
});
|
||||
|
||||
test('문제 · 알림이 보일 줄과, 고쳐야 하는 알림', () {
|
||||
expect(problemField(ConditionProblem.pastDate), ConditionField.date);
|
||||
expect(problemField(ConditionProblem.tooShort), ConditionField.start);
|
||||
expect(issueField(ConditionIssue.multiDay), ConditionField.date);
|
||||
expect(issueField(ConditionIssue.unclearRequest), isNull);
|
||||
expect(isBlockingIssue(ConditionIssue.unknownPlace), isFalse);
|
||||
expect(isBlockingIssue(ConditionIssue.unsupportedArea), isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('AiCourseDraft', () {
|
||||
final parse = ConditionParse(
|
||||
conditions: _conditions(),
|
||||
inferred: const {ConditionField.date, ConditionField.party},
|
||||
issues: const [ConditionIssue.multiDay, ConditionIssue.unknownPlace],
|
||||
weather: const CourseWeather(
|
||||
date: '2026-11-14',
|
||||
precipitationProbability: 60,
|
||||
),
|
||||
);
|
||||
|
||||
test('처음 해석하면 조건 · 뽑은 항목 · 알림 · 날씨를 그대로 채운다', () {
|
||||
final draft = const AiCourseDraft(
|
||||
text: '토요일 수성못',
|
||||
).applyParse(parse, '토요일 수성못');
|
||||
expect(draft.conditions!.date, '2026-11-14');
|
||||
expect(draft.inferred, parse.inferred);
|
||||
expect(draft.issues, parse.issues);
|
||||
expect(draft.weatherForDate!.precipitationProbability, 60);
|
||||
expect(draft.hasBlockingIssue, isTrue);
|
||||
expect(draft.parsedText, '토요일 수성못');
|
||||
});
|
||||
|
||||
test('사용자가 고친 항목은 다시 해석해도 지키고, 그 항목의 알림은 풀린다', () {
|
||||
final edited = const AiCourseDraft(text: 'a')
|
||||
.applyParse(parse, 'a')
|
||||
.applyEdit(_conditions(date: '2026-11-15', party: 4), {
|
||||
ConditionField.date,
|
||||
ConditionField.party,
|
||||
});
|
||||
expect(edited.issues, [ConditionIssue.unknownPlace]);
|
||||
expect(edited.inferred, isEmpty);
|
||||
// 날짜를 고쳐 해석한 날과 달라졌다 — 날씨를 보이지 않는다
|
||||
expect(edited.weatherForDate, isNull);
|
||||
|
||||
final reparsed = edited.applyParse(parse, 'b');
|
||||
expect(reparsed.conditions!.date, '2026-11-15');
|
||||
expect(reparsed.conditions!.party, 4);
|
||||
expect(reparsed.issues, [ConditionIssue.unknownPlace]);
|
||||
expect(reparsed.inferred, isEmpty);
|
||||
expect(reparsed.hasBlockingIssue, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('만드는 중 단계', () {
|
||||
test('서버 단계 → 지금 줄 · 진행 막대', () {
|
||||
expect(activeStepIndex(GenerationStatus.queued, null), 0);
|
||||
expect(
|
||||
activeStepIndex(GenerationStatus.running, GenerationStage.planning),
|
||||
2,
|
||||
);
|
||||
expect(activeStepIndex(GenerationStatus.succeeded, null), 4);
|
||||
expect(
|
||||
progressValue(GenerationStatus.running, GenerationStage.filtering),
|
||||
0.3,
|
||||
);
|
||||
expect(progressValue(GenerationStatus.succeeded, null), 1);
|
||||
});
|
||||
|
||||
test('지금 줄 앞은 끝남, 뒤는 기다림', () {
|
||||
expect(stepState(GenerationStep.search, 2), GenerationStepState.done);
|
||||
expect(stepState(GenerationStep.plan, 2), GenerationStepState.current);
|
||||
expect(stepState(GenerationStep.finish, 2), GenerationStepState.waiting);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:wandergo/features/ai_course/data/models/course_conditions.dart';
|
||||
import 'package:wandergo/features/ai_course/data/models/course_generation.dart';
|
||||
import 'package:wandergo/features/location/domain/base_location.dart';
|
||||
import 'package:wandergo/features/place/data/models/place_models.dart';
|
||||
import 'package:wandergo/shared/models/source_models.dart';
|
||||
|
||||
/// 백엔드 CourseConditionsDto 모양
|
||||
Map<String, dynamic> conditionsJson({
|
||||
Map<String, dynamic> area = const {'type': 'neighborhood', 'key': 'apsan'},
|
||||
Object? pet,
|
||||
}) => {
|
||||
'area': area,
|
||||
'date': '2026-11-14',
|
||||
'start': '14:00',
|
||||
'end': '20:00',
|
||||
'party': 3,
|
||||
'pet': pet,
|
||||
'mobility': 'transit',
|
||||
'indoorIfRain': false,
|
||||
};
|
||||
|
||||
/// 백엔드 CourseStopDto 모양
|
||||
Map<String, dynamic> stopJson(String id, {Map<String, dynamic>? legToNext}) => {
|
||||
'kind': 'place',
|
||||
'id': id,
|
||||
'name': '앞산카페거리',
|
||||
'category': 'attraction',
|
||||
'eventKind': null,
|
||||
'photo': {'thumbUrl': 'https://img/1.jpg', 'license': 'Type1'},
|
||||
'arrive': '2026-11-14T05:00:00.000Z',
|
||||
'leave': '2026-11-14T06:10:00.000Z',
|
||||
'minutes': 70,
|
||||
'reason': '실외 관광지로 카페가 모여 있어요',
|
||||
'facts': {
|
||||
'hoursOnDate': [
|
||||
{'start': '10:00', 'end': '22:00'},
|
||||
],
|
||||
'hoursText': '10:00~22:00',
|
||||
'hoursOrigin': 'public',
|
||||
'petPolicy': 'partial',
|
||||
'petOrigin': 'curation',
|
||||
'petSizes': ['small', 'huge'],
|
||||
'verifiedAt': '2026-10-02T00:00:00.000Z',
|
||||
'setting': 'outdoor',
|
||||
'eventPeriod': null,
|
||||
'provider': '한국관광공사',
|
||||
},
|
||||
'warnings': ['pet_unknown', 'new_warning'],
|
||||
'legToNext': legToNext,
|
||||
};
|
||||
|
||||
void main() {
|
||||
group('CourseConditions', () {
|
||||
test('백엔드 모양을 읽고 그대로 다시 쓴다', () {
|
||||
final json = conditionsJson(
|
||||
pet: {'kind': 'dog', 'size': 'small', 'count': 2},
|
||||
);
|
||||
final conditions = CourseConditions.fromJson(json)!;
|
||||
expect(conditions.area, const NeighborhoodArea(Neighborhood.apsan));
|
||||
expect(
|
||||
conditions.pet,
|
||||
const PetCondition(kind: PetKind.dog, size: PetSize.small, count: 2),
|
||||
);
|
||||
expect(conditions.mobility, Mobility.transit);
|
||||
expect(conditions.indoorIfRain, isFalse);
|
||||
expect(conditions.toJson(), json);
|
||||
});
|
||||
|
||||
test('지역 세 가지를 읽고 쓴다', () {
|
||||
for (final area in [
|
||||
{'type': 'neighborhood', 'key': 'downtown'},
|
||||
{'type': 'point', 'lat': 35.87, 'lng': 128.59},
|
||||
{'type': 'place', 'placeId': 'p-1'},
|
||||
]) {
|
||||
expect(CourseArea.fromJson(area)!.toJson(), area);
|
||||
}
|
||||
});
|
||||
|
||||
test('모르는 동네 · 빠진 날짜는 읽지 않는다', () {
|
||||
expect(
|
||||
CourseArea.fromJson({'type': 'neighborhood', 'key': 'seongsu'}),
|
||||
isNull,
|
||||
);
|
||||
final noDate = conditionsJson()..remove('date');
|
||||
expect(CourseConditions.fromJson(noDate), isNull);
|
||||
});
|
||||
|
||||
test('반려동물만 바꾸고 나머지는 그대로 둔다', () {
|
||||
final conditions = CourseConditions.fromJson(
|
||||
conditionsJson(pet: {'kind': 'cat', 'size': null, 'count': 1}),
|
||||
)!;
|
||||
final none = conditions.withPet(null);
|
||||
expect(none.pet, isNull);
|
||||
expect(none.date, conditions.date);
|
||||
expect(conditions.copyWith(party: 5).pet, conditions.pet);
|
||||
});
|
||||
});
|
||||
|
||||
group('ConditionParse', () {
|
||||
test('뽑은 항목 · 되물을 것 · 날씨를 읽고 모르는 값은 버린다', () {
|
||||
final parse = ConditionParse.fromJson({
|
||||
'conditions': conditionsJson(),
|
||||
'inferred': ['area', 'date', 'start', 'mood'],
|
||||
'issues': ['multi_day', 'brand_new_issue'],
|
||||
'weather': {
|
||||
'date': '2026-11-14',
|
||||
'precipitationProbability': 60,
|
||||
'precipitationType': 'rain',
|
||||
'sky': 'cloudy',
|
||||
'temperatureMin': 9,
|
||||
'temperatureMax': 14.5,
|
||||
'issuedAt': '2026-11-12T02:00:00.000Z',
|
||||
},
|
||||
})!;
|
||||
expect(parse.inferred, {
|
||||
ConditionField.area,
|
||||
ConditionField.date,
|
||||
ConditionField.start,
|
||||
});
|
||||
expect(parse.issues, [ConditionIssue.multiDay]);
|
||||
expect(parse.weather!.precipitationProbability, 60);
|
||||
expect(parse.weather!.precipitationType, PrecipitationType.rain);
|
||||
expect(parse.weather!.temperatureMax, 14.5);
|
||||
});
|
||||
|
||||
test('조건이 없으면 null', () {
|
||||
expect(ConditionParse.fromJson({'inferred': []}), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('CourseGeneration', () {
|
||||
test('진행 중 — 단계와 숫자를 읽는다', () {
|
||||
final generation = CourseGeneration.fromJson({
|
||||
'id': 'g1',
|
||||
'status': 'running',
|
||||
'stage': 'filtering',
|
||||
'progress': {'found': 128, 'kept': null},
|
||||
'result': null,
|
||||
'failure': null,
|
||||
'remaining': 1,
|
||||
'rating': null,
|
||||
});
|
||||
expect(generation.status, GenerationStatus.running);
|
||||
expect(generation.status.isActive, isTrue);
|
||||
expect(generation.stage, GenerationStage.filtering);
|
||||
expect(generation.found, 128);
|
||||
expect(generation.kept, isNull);
|
||||
});
|
||||
|
||||
test('실패 — 코드와 AI 가 남긴 이유', () {
|
||||
final generation = CourseGeneration.fromJson({
|
||||
'id': 'g1',
|
||||
'status': 'failed',
|
||||
'stage': null,
|
||||
'progress': {'found': 3, 'kept': 1},
|
||||
'result': null,
|
||||
'failure': {
|
||||
'code': 'AI_003',
|
||||
'notices': ['반려견과 들어갈 수 있는 실내가 없어요'],
|
||||
},
|
||||
'remaining': 2,
|
||||
'rating': null,
|
||||
});
|
||||
expect(generation.status.isActive, isFalse);
|
||||
expect(generation.failure!.code, 'AI_003');
|
||||
expect(generation.failure!.notices, hasLength(1));
|
||||
});
|
||||
|
||||
test('결과 — 장소 · 사실 값 · 바꾸기 후보 · 알림을 읽는다', () {
|
||||
final generation = CourseGeneration.fromJson({
|
||||
'id': 'g1',
|
||||
'status': 'succeeded',
|
||||
'stage': null,
|
||||
'progress': {'found': 128, 'kept': 31},
|
||||
'result': {
|
||||
'outcome': 'partial',
|
||||
'title': '앞산 카페와 산책',
|
||||
'summary': '앞산 카페거리에서 시작해요.',
|
||||
'notices': ['식당 동반 정보가 없어요'],
|
||||
'planNotices': [
|
||||
{'code': 'stop_removed', 'name': '안일사', 'cause': 'window'},
|
||||
{'code': 'unknown_notice'},
|
||||
],
|
||||
'weather': null,
|
||||
'window': {'date': '2026-11-14', 'start': '14:00', 'end': '20:00'},
|
||||
'mobility': 'walk',
|
||||
'totals': {
|
||||
'stops': 2,
|
||||
'totalMinutes': 350,
|
||||
'travelMinutes': 19,
|
||||
'petConfirmed': 1,
|
||||
},
|
||||
'stops': [
|
||||
stopJson(
|
||||
'p1',
|
||||
legToNext: {
|
||||
'minutes': 6,
|
||||
'meters': 450,
|
||||
'mode': 'walk',
|
||||
'estimated': true,
|
||||
},
|
||||
),
|
||||
stopJson('p2'),
|
||||
],
|
||||
'alternates': [
|
||||
{
|
||||
...stopJson('p9'),
|
||||
'forIndex': 1,
|
||||
'legFromPrev': {
|
||||
'minutes': 9,
|
||||
'meters': 700,
|
||||
'mode': 'walk',
|
||||
'estimated': true,
|
||||
},
|
||||
},
|
||||
],
|
||||
'sources': ['한국관광공사 TourAPI · 공공누리 제1유형'],
|
||||
},
|
||||
'failure': null,
|
||||
'remaining': 1,
|
||||
'rating': 'like',
|
||||
});
|
||||
final result = generation.result!;
|
||||
expect(result.outcome, CourseOutcome.partial);
|
||||
expect(result.date, '2026-11-14');
|
||||
expect(result.planNotices, hasLength(1));
|
||||
expect(result.planNotices.single.code, PlanNoticeCode.stopRemoved);
|
||||
expect(result.totals.travelMinutes, 19);
|
||||
expect(result.stops, hasLength(2));
|
||||
|
||||
final first = result.stops.first;
|
||||
expect(first.arrive, DateTime.utc(2026, 11, 14, 5));
|
||||
expect(first.photo!.license, ImageLicense.type1);
|
||||
expect(first.facts.hoursOnDate, [(start: '10:00', end: '22:00')]);
|
||||
expect(first.facts.petPolicy, PetPolicy.partial);
|
||||
expect(first.facts.petOrigin, ValueOrigin.curation);
|
||||
expect(first.facts.petSizes, [PetSize.small]);
|
||||
expect(first.facts.setting, StopSetting.outdoor);
|
||||
expect(first.warnings, [StopWarning.petUnknown]);
|
||||
expect(first.legToNext!.meters, 450);
|
||||
expect(result.stops.last.legToNext, isNull);
|
||||
|
||||
final alternate = result.alternates.single;
|
||||
expect(alternate.forIndex, 1);
|
||||
expect(alternate.stop.id, 'p9');
|
||||
expect(alternate.legFromPrev!.minutes, 9);
|
||||
expect(generation.rating, CourseRating.like);
|
||||
});
|
||||
});
|
||||
|
||||
test('CourseQuota — 남은 횟수와 대상', () {
|
||||
final quota = CourseQuota.fromJson({
|
||||
'limit': 2,
|
||||
'used': 1,
|
||||
'remaining': 1,
|
||||
'resetsAt': '2026-11-12T15:00:00.000Z',
|
||||
'tier': 'guest',
|
||||
});
|
||||
expect(quota.remaining, 1);
|
||||
expect(quota.tier, QuotaTier.guest);
|
||||
expect(quota.resetsAt, DateTime.utc(2026, 11, 12, 15));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,712 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:wandergo/core/api/api_exception.dart';
|
||||
import 'package:wandergo/core/clock.dart';
|
||||
import 'package:wandergo/core/l10n/l10n.dart';
|
||||
import 'package:wandergo/core/l10n/locale_provider.dart';
|
||||
import 'package:wandergo/core/router/app_routes.dart';
|
||||
import 'package:wandergo/core/storage/preferences.dart';
|
||||
import 'package:wandergo/core/theme/app_theme.dart';
|
||||
import 'package:wandergo/features/ai_course/data/models/course_conditions.dart';
|
||||
import 'package:wandergo/features/ai_course/data/models/course_generation.dart';
|
||||
import 'package:wandergo/features/ai_course/data/repositories/ai_course_repository.dart';
|
||||
import 'package:wandergo/features/ai_course/presentation/pages/ai_course_generation_page.dart';
|
||||
import 'package:wandergo/features/ai_course/presentation/pages/ai_course_input_page.dart';
|
||||
import 'package:wandergo/features/ai_course/presentation/providers/ai_course_providers.dart';
|
||||
import 'package:wandergo/features/ai_course/presentation/widgets/condition_sheets.dart';
|
||||
import 'package:wandergo/features/location/domain/base_location.dart';
|
||||
import 'package:wandergo/features/location/presentation/providers/base_location_provider.dart';
|
||||
import 'package:wandergo/features/place/data/models/place_models.dart'
|
||||
show PetPolicy, PetSize;
|
||||
|
||||
/// 2026-11-12(목) 한국 12:00
|
||||
final DateTime _now = DateTime.utc(2026, 11, 12, 3);
|
||||
|
||||
const CourseConditions _conditions = CourseConditions(
|
||||
area: NeighborhoodArea(Neighborhood.suseongmot),
|
||||
date: '2026-11-14',
|
||||
start: '14:00',
|
||||
end: '20:00',
|
||||
party: 2,
|
||||
pet: PetCondition(kind: PetKind.dog),
|
||||
mobility: Mobility.walk,
|
||||
indoorIfRain: true,
|
||||
);
|
||||
|
||||
CourseStop _stop(String id, String name, int hour) => CourseStop(
|
||||
kind: StopKind.place,
|
||||
id: id,
|
||||
name: name,
|
||||
category: 'nature',
|
||||
arrive: DateTime.utc(2026, 11, 14, hour - 9),
|
||||
leave: DateTime.utc(2026, 11, 14, hour - 8),
|
||||
minutes: 60,
|
||||
reason: '걷기 좋은 호숫가',
|
||||
facts: const StopFacts(),
|
||||
);
|
||||
|
||||
final CourseResult _result = CourseResult(
|
||||
outcome: CourseOutcome.course,
|
||||
title: '수성못 반려견 산책',
|
||||
summary: '호숫가를 따라 걸어요.',
|
||||
date: '2026-11-14',
|
||||
mobility: Mobility.walk,
|
||||
totals: const CourseTotals(
|
||||
stops: 2,
|
||||
totalMinutes: 180,
|
||||
travelMinutes: 10,
|
||||
petConfirmed: 1,
|
||||
),
|
||||
stops: [_stop('p1', '수성못 유원지', 14), _stop('p2', '수성못상화동산', 16)],
|
||||
);
|
||||
|
||||
/// 결과 화면 확인용 — 사실 · 경고 · 알림 · 날씨와 2번 자리의 바꾸기 후보
|
||||
final CourseResult _resultWithAlternate = CourseResult(
|
||||
outcome: CourseOutcome.partial,
|
||||
title: '수성못 반려견 산책',
|
||||
summary: '호숫가를 따라 걸어요.',
|
||||
notices: const ['반려견과 들어갈 수 있는 식당이 없어 식사를 넣지 못했어요'],
|
||||
planNotices: const [
|
||||
PlanNotice(code: PlanNoticeCode.stopRemoved, name: '안일사', cause: 'window'),
|
||||
],
|
||||
weather: const CourseWeather(
|
||||
date: '2026-11-14',
|
||||
precipitationProbability: 20,
|
||||
temperatureMin: 9,
|
||||
temperatureMax: 15,
|
||||
),
|
||||
date: '2026-11-14',
|
||||
mobility: Mobility.walk,
|
||||
totals: const CourseTotals(
|
||||
stops: 2,
|
||||
totalMinutes: 130,
|
||||
travelMinutes: 10,
|
||||
petConfirmed: 1,
|
||||
),
|
||||
stops: [
|
||||
CourseStop(
|
||||
kind: StopKind.place,
|
||||
id: 'p1',
|
||||
name: '수성못 유원지',
|
||||
category: 'nature',
|
||||
arrive: DateTime.utc(2026, 11, 14, 5),
|
||||
leave: DateTime.utc(2026, 11, 14, 6),
|
||||
minutes: 60,
|
||||
reason: '호숫가를 걸어요',
|
||||
facts: const StopFacts(
|
||||
hoursOnDate: [(start: '09:00', end: '22:00')],
|
||||
petPolicy: PetPolicy.partial,
|
||||
provider: '한국관광공사',
|
||||
),
|
||||
legToNext: const CourseLeg(minutes: 10, meters: 700, mode: Mobility.walk),
|
||||
),
|
||||
CourseStop(
|
||||
kind: StopKind.place,
|
||||
id: 'p2',
|
||||
name: '수성못상화동산',
|
||||
category: 'nature',
|
||||
arrive: DateTime.utc(2026, 11, 14, 6, 10),
|
||||
leave: DateTime.utc(2026, 11, 14, 7, 10),
|
||||
minutes: 60,
|
||||
reason: '언덕에서 호수를 봐요',
|
||||
facts: const StopFacts(),
|
||||
warnings: const [StopWarning.petUnknown],
|
||||
),
|
||||
],
|
||||
alternates: [
|
||||
CourseAlternate(
|
||||
forIndex: 1,
|
||||
stop: CourseStop(
|
||||
kind: StopKind.place,
|
||||
id: 'p9',
|
||||
name: '들안길 공원',
|
||||
category: 'nature',
|
||||
arrive: DateTime.utc(2026, 11, 14, 6, 10),
|
||||
leave: DateTime.utc(2026, 11, 14, 7, 10),
|
||||
minutes: 60,
|
||||
reason: '반려견과 걷기 좋은 공원',
|
||||
facts: const StopFacts(petPolicy: PetPolicy.allowed),
|
||||
),
|
||||
legFromPrev: const CourseLeg(
|
||||
minutes: 4,
|
||||
meters: 300,
|
||||
mode: Mobility.walk,
|
||||
),
|
||||
),
|
||||
],
|
||||
sources: const ['한국관광공사 TourAPI · 공공누리 제1유형'],
|
||||
);
|
||||
|
||||
/// 가짜 AI 코스 API — 부른 것을 기록하고, 진행 조회는 [progress] 를 차례로 준다(끝에 닿으면 마지막 것)
|
||||
class _FakeAiRepository implements AiCourseRepository {
|
||||
_FakeAiRepository({
|
||||
this.parse,
|
||||
this.progress = const [],
|
||||
this.remaining = 2,
|
||||
this.createError,
|
||||
});
|
||||
|
||||
final ConditionParse? parse;
|
||||
final List<CourseGeneration> progress;
|
||||
final int remaining;
|
||||
final Object? createError;
|
||||
|
||||
final List<String> parsedTexts = [];
|
||||
final List<CourseConditions> created = [];
|
||||
final List<String> canceled = [];
|
||||
final List<String> polled = [];
|
||||
final List<String?> previousIds = [];
|
||||
final List<CourseRating> rated = [];
|
||||
int _polls = 0;
|
||||
|
||||
@override
|
||||
Future<CourseQuota> quota({required String lang}) async =>
|
||||
CourseQuota(limit: 2, used: 2 - remaining, remaining: remaining);
|
||||
|
||||
@override
|
||||
Future<ConditionParse> parseConditions({
|
||||
required String text,
|
||||
required CourseArea baseArea,
|
||||
required String lang,
|
||||
}) async {
|
||||
parsedTexts.add(text);
|
||||
return parse!;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<GenerationCreated> create({
|
||||
required String request,
|
||||
required CourseConditions conditions,
|
||||
String? previousGenerationId,
|
||||
required String lang,
|
||||
}) async {
|
||||
final error = createError;
|
||||
if (error != null) throw error;
|
||||
created.add(conditions);
|
||||
previousIds.add(previousGenerationId);
|
||||
// 만들 때마다 새 id — 다시 만들기가 새 생성으로 가는지 본다
|
||||
return GenerationCreated(
|
||||
id: 'g${created.length}',
|
||||
status: GenerationStatus.queued,
|
||||
remaining: 1,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<CourseGeneration> get(String id, {required String lang}) async {
|
||||
polled.add(id);
|
||||
final index = _polls < progress.length ? _polls : progress.length - 1;
|
||||
_polls++;
|
||||
return progress[index];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<CourseGeneration> cancel(String id, {required String lang}) async {
|
||||
canceled.add(id);
|
||||
return CourseGeneration(id: id, status: GenerationStatus.canceled);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<CourseGeneration> rate(
|
||||
String id,
|
||||
CourseRating value, {
|
||||
required String lang,
|
||||
}) async {
|
||||
rated.add(value);
|
||||
final last = progress.last;
|
||||
return CourseGeneration(
|
||||
id: id,
|
||||
status: last.status,
|
||||
found: last.found,
|
||||
kept: last.kept,
|
||||
result: last.result,
|
||||
remaining: last.remaining,
|
||||
rating: value,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pump(
|
||||
WidgetTester tester,
|
||||
_FakeAiRepository repo, {
|
||||
String initialLocation = AppRoutes.aiCourse,
|
||||
Map<String, Object> prefs = const {},
|
||||
Size size = const Size(800, 2000),
|
||||
}) async {
|
||||
SharedPreferences.setMockInitialValues(prefs);
|
||||
final preferences = await SharedPreferences.getInstance();
|
||||
final router = GoRouter(
|
||||
initialLocation: initialLocation,
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: AppRoutes.aiCourse,
|
||||
builder: (context, state) => const AiCourseInputPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: AppRoutes.aiCourseGenerationPattern,
|
||||
builder: (context, state) =>
|
||||
AiCourseGenerationPage(id: state.pathParameters['id']!),
|
||||
),
|
||||
],
|
||||
);
|
||||
addTearDown(router.dispose);
|
||||
await tester.binding.setSurfaceSize(size);
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
sharedPreferencesProvider.overrideWithValue(preferences),
|
||||
aiCourseRepositoryProvider.overrideWithValue(repo),
|
||||
contentLanguageProvider.overrideWithValue('ko'),
|
||||
clockProvider.overrideWithValue(() => _now),
|
||||
basePointProvider.overrideWith(
|
||||
(ref) async => BasePoint(
|
||||
point: Neighborhood.suseongmot.point,
|
||||
choice: const BaseLocationChoice.neighborhood(
|
||||
Neighborhood.suseongmot,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
child: MaterialApp.router(
|
||||
routerConfig: router,
|
||||
theme: AppTheme.light(),
|
||||
locale: const Locale('ko'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
/// 화면을 닫아 남은 타이머(조회 · 입력 멈춤)를 정리한다
|
||||
Future<void> _dispose(WidgetTester tester) async {
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
/// 화면 글자는 단어 단위 줄바꿈용 보이지 않는 글자를 품고 있어, 그 글자를 빼고 찾는다
|
||||
Finder _text(String text) => find.byWidgetPredicate(
|
||||
(w) =>
|
||||
w is Text &&
|
||||
(w.data ?? w.textSpan?.toPlainText() ?? '').replaceAll('', '') == text,
|
||||
);
|
||||
|
||||
Finder _button(String label) => find.ancestor(
|
||||
of: find.text(label),
|
||||
matching: find.byWidgetPredicate((w) => w is ButtonStyleButton),
|
||||
);
|
||||
|
||||
void main() {
|
||||
testWidgets('입력: 문장을 멈추면 조건을 채우고, 만들면 진행을 거쳐 결과를 보인다', (tester) async {
|
||||
final repo = _FakeAiRepository(
|
||||
parse: const ConditionParse(
|
||||
conditions: _conditions,
|
||||
inferred: {ConditionField.date, ConditionField.pet},
|
||||
weather: CourseWeather(
|
||||
date: '2026-11-14',
|
||||
precipitationProbability: 60,
|
||||
),
|
||||
),
|
||||
progress: [
|
||||
const CourseGeneration(
|
||||
id: 'g1',
|
||||
status: GenerationStatus.running,
|
||||
stage: GenerationStage.filtering,
|
||||
found: 128,
|
||||
),
|
||||
const CourseGeneration(
|
||||
id: 'g1',
|
||||
status: GenerationStatus.running,
|
||||
stage: GenerationStage.planning,
|
||||
found: 128,
|
||||
kept: 31,
|
||||
),
|
||||
CourseGeneration(
|
||||
id: 'g1',
|
||||
status: GenerationStatus.succeeded,
|
||||
found: 128,
|
||||
kept: 31,
|
||||
result: _result,
|
||||
),
|
||||
],
|
||||
);
|
||||
await _pump(tester, repo);
|
||||
await tester.pump();
|
||||
expect(find.text('오늘 2번 더 만들 수 있어요'), findsOneWidget);
|
||||
expect(_text('문장을 쓰면 날짜·시간·인원을 채워 드려요.'), findsOneWidget);
|
||||
// 해석 전에는 만들 수 없다
|
||||
expect(
|
||||
tester.widget<ButtonStyleButton>(_button('코스 만들기')).enabled,
|
||||
isFalse,
|
||||
);
|
||||
|
||||
await tester.enterText(find.byType(TextField), '토요일 오후 강아지랑 수성못');
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
expect(repo.parsedTexts, isEmpty, reason: '1초 멈추기 전에는 부르지 않는다');
|
||||
await tester.pump(const Duration(milliseconds: 600));
|
||||
await tester.pump();
|
||||
expect(repo.parsedTexts, ['토요일 오후 강아지랑 수성못']);
|
||||
|
||||
expect(find.text('수성못'), findsOneWidget);
|
||||
expect(find.text('11.14(토)'), findsOneWidget);
|
||||
expect(find.text('14:00–20:00'), findsOneWidget);
|
||||
expect(find.text('2명'), findsOneWidget);
|
||||
expect(find.text('강아지 1마리'), findsOneWidget);
|
||||
expect(find.text('토요일 비 60%'), findsOneWidget);
|
||||
|
||||
// 인원을 고치면 그 값으로 만든다
|
||||
await tester.tap(
|
||||
find.byWidgetPredicate(
|
||||
(w) => w is RoundStepButton && w.label == '인원 늘리기',
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
expect(find.text('3명'), findsOneWidget);
|
||||
|
||||
await tester.tap(_button('코스 만들기'));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
expect(repo.created.single.party, 3);
|
||||
|
||||
// 만드는 중 — 지역 이름과 찾은 수
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
await tester.pump();
|
||||
expect(find.text('코스를 짜고 있어요'), findsOneWidget);
|
||||
expect(_text('수성못 근처에서 128곳을 찾았어요'), findsOneWidget);
|
||||
expect(_text('조건에 맞는 곳을 추리는 중'), findsOneWidget);
|
||||
|
||||
// 다음 조회 — 추린 결과를 요일 영업과 반려동물 조건으로 말한다
|
||||
await tester.pump(kPollInterval);
|
||||
await tester.pump();
|
||||
expect(_text('토요일 영업·반려견 동반으로 31곳을 추렸어요'), findsOneWidget);
|
||||
expect(
|
||||
find.text('오늘 2번 더 만들 수 있어요'),
|
||||
findsNothing,
|
||||
reason: '만드는 중에는 횟수를 보이지 않는다',
|
||||
);
|
||||
|
||||
// 그다음 조회에서 끝났다
|
||||
await tester.pump(kPollInterval);
|
||||
await tester.pump();
|
||||
expect(_text('호숫가를 따라 걸어요.'), findsOneWidget);
|
||||
expect(_text('수성못 유원지'), findsOneWidget);
|
||||
expect(find.text('14:00–15:00 · 60분'), findsOneWidget);
|
||||
await _dispose(tester);
|
||||
});
|
||||
|
||||
testWidgets('결과: 사실 · 알림을 보이고, 한 곳을 바꿨다 되돌리고, 평가하고, 다시 만든다', (tester) async {
|
||||
final repo = _FakeAiRepository(
|
||||
parse: const ConditionParse(conditions: _conditions),
|
||||
progress: [
|
||||
CourseGeneration(
|
||||
id: 'g1',
|
||||
status: GenerationStatus.succeeded,
|
||||
found: 40,
|
||||
kept: 12,
|
||||
result: _resultWithAlternate,
|
||||
remaining: 1,
|
||||
),
|
||||
],
|
||||
);
|
||||
await _pump(tester, repo);
|
||||
await tester.enterText(find.byType(TextField), '토요일 강아지랑 수성못');
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
await tester.tap(_button('코스 만들기'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
|
||||
// 요청 · 요약 · 알림
|
||||
expect(find.text('내가 요청한 코스'), findsOneWidget);
|
||||
expect(_text('“토요일 강아지랑 수성못”'), findsOneWidget);
|
||||
expect(_text('호숫가를 따라 걸어요.'), findsOneWidget);
|
||||
expect(_text('수성못 반려견 산책'), findsNothing, reason: '시안대로 코스 제목은 없다');
|
||||
expect(_text('토요일 비 20% · 9–15°C'), findsOneWidget);
|
||||
expect(_text('반려견과 들어갈 수 있는 식당이 없어 식사를 넣지 못했어요'), findsOneWidget);
|
||||
expect(_text('안일사: 그 시간에 머물 수 없어 코스에서 뺐어요.'), findsOneWidget);
|
||||
expect(find.text('10분'), findsOneWidget, reason: '걸어서 이동 합계');
|
||||
|
||||
// 코스 순서 — 시각 · 분류 · 동반 · 사실 · 경고 · 이동
|
||||
expect(find.text('14:00 출발 · 16:10 마침'), findsOneWidget);
|
||||
expect(find.text('걸어서 10분 · 700m'), findsOneWidget);
|
||||
expect(_text('영업 09:00–22:00 · 출처: 한국관광공사'), findsOneWidget);
|
||||
expect(_text('반려동물 동반 정보가 없어요. 현장 안내를 따라 주세요.'), findsOneWidget);
|
||||
|
||||
// 바꾸기 — 2번 자리를 후보로, 앞 장소에서 오는 이동도 바뀐다
|
||||
final swapRow = find.text('2번 대신 · 자연·공원');
|
||||
await tester.ensureVisible(swapRow);
|
||||
await tester.tap(swapRow);
|
||||
await tester.pump();
|
||||
expect(find.text('2번 원래 장소로 되돌리기'), findsOneWidget);
|
||||
expect(find.text('걸어서 4분 · 300m'), findsOneWidget);
|
||||
expect(find.text('4분'), findsOneWidget, reason: '이동 합계도 다시 센다');
|
||||
expect(
|
||||
_text('반려동물 동반 정보가 없어요. 현장 안내를 따라 주세요.'),
|
||||
findsNothing,
|
||||
reason: '바뀐 장소는 동반이 확인됐다',
|
||||
);
|
||||
|
||||
// 되돌리기
|
||||
await tester.tap(find.text('2번 원래 장소로 되돌리기'));
|
||||
await tester.pump();
|
||||
expect(find.text('2번 대신 · 자연·공원'), findsOneWidget);
|
||||
expect(find.text('걸어서 10분 · 700m'), findsOneWidget);
|
||||
|
||||
// 평가
|
||||
final like = find.byTooltip('좋아요');
|
||||
await tester.ensureVisible(like);
|
||||
await tester.tap(like);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
expect(repo.rated, [CourseRating.like]);
|
||||
expect(find.text('의견 고마워요'), findsOneWidget);
|
||||
|
||||
// 다시 만들기 — 확인 뒤 지난 결과 id 를 넘겨 새 생성으로 간다
|
||||
await tester.tap(_button('다시 만들기').first);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('다시 만들까요?'), findsOneWidget);
|
||||
// 가짜 서버의 남은 횟수(2번)를 그대로 보인다
|
||||
expect(_text('같은 조건으로 다른 곳을 골라 새 코스를 만들어요. 오늘 2번 남아요.'), findsOneWidget);
|
||||
await tester.tap(
|
||||
find.descendant(
|
||||
of: find.byType(BottomSheet),
|
||||
matching: _button('다시 만들기'),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
await tester.pump();
|
||||
expect(repo.previousIds, [null, 'g1']);
|
||||
expect(repo.polled, contains('g2'));
|
||||
await _dispose(tester);
|
||||
});
|
||||
|
||||
testWidgets('입력: 되물을 것이 있으면 그 줄에 알리고, 고치면 만들 수 있다', (tester) async {
|
||||
final repo = _FakeAiRepository(
|
||||
parse: const ConditionParse(
|
||||
conditions: _conditions,
|
||||
issues: [ConditionIssue.multiDay],
|
||||
),
|
||||
);
|
||||
await _pump(tester, repo);
|
||||
await tester.enterText(find.byType(TextField), '1박 2일 수성못');
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
expect(_text('하루 코스만 만들 수 있어요. 날짜 하루를 골라 주세요.'), findsOneWidget);
|
||||
expect(
|
||||
tester.widget<ButtonStyleButton>(_button('코스 만들기')).enabled,
|
||||
isFalse,
|
||||
);
|
||||
|
||||
// 날짜를 고르면 알림이 풀린다
|
||||
await tester.tap(find.text('11.14(토)'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('15'));
|
||||
await tester.tap(find.text('확인'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('11.15(일)'), findsOneWidget);
|
||||
expect(_text('하루 코스만 만들 수 있어요. 날짜 하루를 골라 주세요.'), findsNothing);
|
||||
expect(tester.widget<ButtonStyleButton>(_button('코스 만들기')).enabled, isTrue);
|
||||
await _dispose(tester);
|
||||
});
|
||||
|
||||
testWidgets('입력: 하루 횟수를 다 썼으면 만들지 않고 안내한다', (tester) async {
|
||||
final repo = _FakeAiRepository(
|
||||
parse: const ConditionParse(conditions: _conditions),
|
||||
remaining: 0,
|
||||
);
|
||||
await _pump(tester, repo);
|
||||
await tester.pump();
|
||||
expect(find.text('오늘 만들 수 있는 횟수를 다 썼어요'), findsOneWidget);
|
||||
await tester.enterText(find.byType(TextField), '토요일 수성못');
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
await tester.tap(_button('코스 만들기'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
_text('오늘 만들 수 있는 AI 코스를 모두 만들었어요. 내일 다시 만들 수 있어요.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(repo.created, isEmpty);
|
||||
await _dispose(tester);
|
||||
});
|
||||
|
||||
testWidgets('입력: 휴대폰 폭(390)에서도 조건 줄과 시트가 넘치지 않는다', (tester) async {
|
||||
final repo = _FakeAiRepository(
|
||||
parse: const ConditionParse(
|
||||
conditions: CourseConditions(
|
||||
area: NeighborhoodArea(Neighborhood.apsan),
|
||||
date: '2026-11-14',
|
||||
start: '10:00',
|
||||
end: '22:00',
|
||||
party: 10,
|
||||
pet: PetCondition(kind: PetKind.dog, size: PetSize.medium, count: 5),
|
||||
mobility: Mobility.transit,
|
||||
indoorIfRain: false,
|
||||
),
|
||||
issues: [ConditionIssue.unknownPlace],
|
||||
weather: CourseWeather(
|
||||
date: '2026-11-14',
|
||||
precipitationProbability: 100,
|
||||
),
|
||||
),
|
||||
);
|
||||
await _pump(tester, repo, size: const Size(390, 844));
|
||||
await tester.enterText(find.byType(TextField), '앞산 하루 종일');
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
expect(find.text('앞산 카페거리'), findsOneWidget);
|
||||
expect(find.text('중형견 5마리'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
|
||||
// 시간 · 반려동물 시트
|
||||
await tester.tap(find.text('10:00–22:00'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('12시간'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
await tester.tapAt(const Offset(195, 40));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('중형견 5마리'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('누구와 함께 가요?'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
await _dispose(tester);
|
||||
});
|
||||
|
||||
testWidgets('입력: 이미 만드는 중(AI_004)이면 저장해 둔 생성으로 이어 간다', (tester) async {
|
||||
final repo = _FakeAiRepository(
|
||||
parse: const ConditionParse(conditions: _conditions),
|
||||
createError: DioException(
|
||||
requestOptions: RequestOptions(path: '/course-generations'),
|
||||
error: ApiException.fromCode('AI_004'),
|
||||
),
|
||||
progress: const [
|
||||
CourseGeneration(
|
||||
id: 'g7',
|
||||
status: GenerationStatus.running,
|
||||
stage: GenerationStage.searching,
|
||||
),
|
||||
],
|
||||
);
|
||||
await _pump(tester, repo, prefs: {PrefKeys.pendingGeneration: 'g7'});
|
||||
expect(find.text('만들던 코스가 있어요'), findsOneWidget);
|
||||
|
||||
await tester.enterText(find.byType(TextField), '토요일 수성못');
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
await tester.tap(_button('코스 만들기'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
await tester.pump();
|
||||
expect(find.text('코스를 짜고 있어요'), findsOneWidget);
|
||||
expect(repo.polled, contains('g7'));
|
||||
await _dispose(tester);
|
||||
});
|
||||
|
||||
testWidgets('결과: 휴대폰 폭(390)에서도 넘치지 않고, 입력 없이 이어 보면 다시 만들기를 숨긴다', (
|
||||
tester,
|
||||
) async {
|
||||
final repo = _FakeAiRepository(
|
||||
progress: [
|
||||
CourseGeneration(
|
||||
id: 'g5',
|
||||
status: GenerationStatus.succeeded,
|
||||
result: _resultWithAlternate,
|
||||
),
|
||||
],
|
||||
);
|
||||
await _pump(
|
||||
tester,
|
||||
repo,
|
||||
initialLocation: AppRoutes.aiCourseGeneration('g5'),
|
||||
size: const Size(390, 844),
|
||||
);
|
||||
await tester.pump(const Duration(milliseconds: 10));
|
||||
await tester.pump();
|
||||
expect(_text('호숫가를 따라 걸어요.'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
// 이 기기에서 입력한 요청이 없다(앱을 다시 켜 이어 봄) — 요청 칸과 다시 만들기를 숨긴다
|
||||
expect(find.text('내가 요청한 코스'), findsNothing);
|
||||
expect(_button('다시 만들기'), findsNothing);
|
||||
|
||||
await tester.dragUntilVisible(
|
||||
_text('이 코스, 마음에 드세요?'),
|
||||
find.byType(ListView),
|
||||
const Offset(0, -300),
|
||||
);
|
||||
await tester.pump();
|
||||
expect(tester.takeException(), isNull);
|
||||
await _dispose(tester);
|
||||
});
|
||||
|
||||
testWidgets('만드는 중: 실패하면 사전 문구와 AI 가 남긴 이유를 보인다', (tester) async {
|
||||
final repo = _FakeAiRepository(
|
||||
progress: const [
|
||||
CourseGeneration(
|
||||
id: 'g9',
|
||||
status: GenerationStatus.failed,
|
||||
failure: GenerationFailure(
|
||||
code: 'AI_003',
|
||||
notices: ['반려견과 함께 들어갈 수 있는 실내가 없어요'],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
await _pump(
|
||||
tester,
|
||||
repo,
|
||||
initialLocation: AppRoutes.aiCourseGeneration('g9'),
|
||||
);
|
||||
// 첫 조회 타이머가 돌 시간을 준다
|
||||
await tester.pump(const Duration(milliseconds: 10));
|
||||
await tester.pump();
|
||||
expect(_text('코스를 만들지 못했어요'), findsOneWidget);
|
||||
expect(
|
||||
_text('조건에 맞는 장소를 찾지 못했어요. 시간이나 조건을 바꿔 다시 만들어 보세요.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(_text('반려견과 함께 들어갈 수 있는 실내가 없어요'), findsOneWidget);
|
||||
await _dispose(tester);
|
||||
});
|
||||
|
||||
testWidgets('만드는 중: 취소하면 서버에 취소를 보낸다', (tester) async {
|
||||
final repo = _FakeAiRepository(
|
||||
progress: const [
|
||||
CourseGeneration(
|
||||
id: 'g2',
|
||||
status: GenerationStatus.running,
|
||||
stage: GenerationStage.planning,
|
||||
found: 40,
|
||||
kept: 12,
|
||||
),
|
||||
],
|
||||
);
|
||||
await _pump(
|
||||
tester,
|
||||
repo,
|
||||
initialLocation: AppRoutes.aiCourseGeneration('g2'),
|
||||
);
|
||||
await tester.pump(const Duration(milliseconds: 10));
|
||||
await tester.pump();
|
||||
expect(_text('근처에서 40곳을 찾았어요'), findsOneWidget);
|
||||
expect(_text('조건에 맞는 12곳을 추렸어요'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('취소'));
|
||||
await tester.pump();
|
||||
expect(repo.canceled, ['g2']);
|
||||
await _dispose(tester);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:wandergo/features/ai_course/data/models/course_conditions.dart';
|
||||
import 'package:wandergo/features/ai_course/data/models/course_generation.dart';
|
||||
import 'package:wandergo/features/ai_course/domain/course_swap.dart';
|
||||
import 'package:wandergo/features/place/data/models/place_models.dart';
|
||||
|
||||
CourseStop _stop(
|
||||
String id, {
|
||||
required int hour,
|
||||
PetPolicy pet = PetPolicy.unknown,
|
||||
int? legMinutes,
|
||||
}) => CourseStop(
|
||||
kind: StopKind.place,
|
||||
id: id,
|
||||
name: id,
|
||||
category: 'nature',
|
||||
arrive: DateTime.utc(2026, 11, 14, hour - 9),
|
||||
leave: DateTime.utc(2026, 11, 14, hour - 8),
|
||||
minutes: 60,
|
||||
reason: '',
|
||||
facts: StopFacts(petPolicy: pet),
|
||||
legToNext: legMinutes == null
|
||||
? null
|
||||
: CourseLeg(minutes: legMinutes, meters: 500, mode: Mobility.walk),
|
||||
);
|
||||
|
||||
CourseLeg _leg(int minutes) =>
|
||||
CourseLeg(minutes: minutes, meters: 300, mode: Mobility.walk);
|
||||
|
||||
final CourseResult _result = CourseResult(
|
||||
outcome: CourseOutcome.course,
|
||||
title: '',
|
||||
summary: '',
|
||||
date: '2026-11-14',
|
||||
mobility: Mobility.walk,
|
||||
totals: const CourseTotals(
|
||||
stops: 3,
|
||||
totalMinutes: 300,
|
||||
travelMinutes: 20,
|
||||
petConfirmed: 1,
|
||||
),
|
||||
stops: [
|
||||
_stop('a', hour: 14, pet: PetPolicy.allowed, legMinutes: 10),
|
||||
_stop('b', hour: 16, legMinutes: 10),
|
||||
_stop('c', hour: 18),
|
||||
],
|
||||
alternates: [
|
||||
CourseAlternate(
|
||||
forIndex: 1,
|
||||
stop: _stop('b2', hour: 16, pet: PetPolicy.partial, legMinutes: 3),
|
||||
legFromPrev: _leg(4),
|
||||
),
|
||||
CourseAlternate(forIndex: 0, stop: _stop('a2', hour: 14, legMinutes: 8)),
|
||||
CourseAlternate(forIndex: 9, stop: _stop('z', hour: 20)),
|
||||
],
|
||||
);
|
||||
|
||||
void main() {
|
||||
test('바꾸지 않으면 그대로', () {
|
||||
expect(stopsWithSwap(_result, null).map((s) => s.id), ['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
test('자리를 바꾸면 그 장소와 앞 장소에서 오는 이동이 바뀐다', () {
|
||||
final stops = stopsWithSwap(_result, 1);
|
||||
expect(stops.map((s) => s.id), ['a', 'b2', 'c']);
|
||||
expect(stops[0].legToNext!.minutes, 4, reason: '앞 장소의 이동은 legFromPrev');
|
||||
expect(stops[1].legToNext!.minutes, 3, reason: '다음 장소로의 이동은 후보에 들어 있다');
|
||||
});
|
||||
|
||||
test('첫 자리를 바꾸면 앞 장소가 없다', () {
|
||||
final stops = stopsWithSwap(_result, 0);
|
||||
expect(stops.map((s) => s.id), ['a2', 'b', 'c']);
|
||||
expect(stops[0].legToNext!.minutes, 8);
|
||||
});
|
||||
|
||||
test('자리마다 후보 하나 — 범위 밖 자리는 버린다', () {
|
||||
expect(alternatesBySlot(_result).keys.toSet(), {0, 1});
|
||||
});
|
||||
|
||||
test('숫자 요약을 장소에서 다시 센다', () {
|
||||
final before = totalsOf(stopsWithSwap(_result, null));
|
||||
expect(before.travelMinutes, 20);
|
||||
expect(before.petConfirmed, 1);
|
||||
expect(before.totalMinutes, 300);
|
||||
|
||||
final after = totalsOf(stopsWithSwap(_result, 1));
|
||||
expect(after.travelMinutes, 7);
|
||||
expect(after.petConfirmed, 2);
|
||||
expect(totalsOf(const []).stops, 0);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:wandergo/core/api/api_exception.dart';
|
||||
import 'package:wandergo/core/l10n/l10n.dart';
|
||||
import 'package:wandergo/core/theme/app_theme.dart';
|
||||
import 'package:wandergo/shared/widgets/app_switch.dart';
|
||||
import 'package:wandergo/shared/widgets/load_error.dart';
|
||||
|
||||
Widget _wrap(Widget child) => MaterialApp(
|
||||
locale: const Locale('ko'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
theme: AppTheme.light(),
|
||||
home: Scaffold(body: Center(child: child)),
|
||||
);
|
||||
|
||||
/// 인터셉터가 실어 주는 모양 그대로 — DioException.error 에 ApiException
|
||||
DioException _failure({String? traceId}) => DioException(
|
||||
requestOptions: RequestOptions(path: '/places'),
|
||||
error: ApiException.fromCode('SYS_001', traceId: traceId),
|
||||
);
|
||||
|
||||
/// keepAll 이 넣은 단어 잇기 문자를 빼고 글자를 비교한다
|
||||
Finder _text(String text) => find.byWidgetPredicate(
|
||||
(w) =>
|
||||
w is Text &&
|
||||
(w.data ?? w.textSpan?.toPlainText() ?? '').replaceAll('', '') == text,
|
||||
);
|
||||
|
||||
void main() {
|
||||
group('LoadErrorBlock', () {
|
||||
testWidgets('사전 문구 · 다시 시도와 오류 코드 · 추적 ID 앞 8자를 보인다', (tester) async {
|
||||
var retried = 0;
|
||||
await tester.pumpWidget(
|
||||
_wrap(
|
||||
LoadErrorBlock(
|
||||
error: _failure(traceId: '3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d'),
|
||||
onRetry: () => retried++,
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(_text('일시적인 시스템 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.'), findsOneWidget);
|
||||
expect(find.text('오류 코드 SYS_001 · 추적 ID 3f1c0a2e'), findsOneWidget);
|
||||
await tester.tap(find.text('다시 시도'));
|
||||
expect(retried, 1);
|
||||
});
|
||||
|
||||
testWidgets('응답이 없던 실패(추적 ID 없음)는 오류 코드만 보인다', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_wrap(LoadErrorBlock(error: _failure(), onRetry: () {})),
|
||||
);
|
||||
expect(find.text('오류 코드 SYS_001'), findsOneWidget);
|
||||
});
|
||||
|
||||
test('추적 ID 는 ApiException 에서만 꺼낸다', () {
|
||||
expect(traceIdOf(_failure(traceId: 'abc')), 'abc');
|
||||
expect(traceIdOf(StateError('x')), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('AppSwitch', () {
|
||||
testWidgets('누르면 반대 값을 알리고, 화면 읽기에는 켜짐 상태와 이름을 준다', (tester) async {
|
||||
final handle = tester.ensureSemantics();
|
||||
bool? changed;
|
||||
await tester.pumpWidget(
|
||||
_wrap(
|
||||
AppSwitch(
|
||||
value: true,
|
||||
onChanged: (value) => changed = value,
|
||||
semanticLabel: '비가 오면 실내 위주로',
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.byType(AppSwitch));
|
||||
expect(changed, isFalse);
|
||||
expect(
|
||||
tester.getSemantics(find.byType(AppSwitch)),
|
||||
matchesSemantics(
|
||||
label: '비가 오면 실내 위주로',
|
||||
hasToggledState: true,
|
||||
isToggled: true,
|
||||
hasEnabledState: true,
|
||||
isEnabled: true,
|
||||
hasTapAction: true,
|
||||
),
|
||||
);
|
||||
handle.dispose();
|
||||
});
|
||||
|
||||
testWidgets('onChanged 가 없으면 누를 수 없다', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_wrap(
|
||||
const AppSwitch(
|
||||
value: false,
|
||||
onChanged: null,
|
||||
semanticLabel: '비가 오면 실내 위주로',
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.byType(AppSwitch));
|
||||
await tester.pump();
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -6,6 +6,8 @@ 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/ai_course/data/models/course_generation.dart';
|
||||
import 'package:wandergo/features/ai_course/data/repositories/ai_course_repository.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';
|
||||
@@ -178,6 +180,19 @@ class _FakePlaceRepository implements PlaceRepository {
|
||||
}
|
||||
}
|
||||
|
||||
/// 가짜 AI 코스 API — 홈 'AI에게 코스 맡기기' 칸이 남은 횟수만 묻는다(화면 흐름은 ai_course_pages_test)
|
||||
class _FakeAiCourseRepository implements AiCourseRepository {
|
||||
const _FakeAiCourseRepository();
|
||||
|
||||
@override
|
||||
Future<CourseQuota> quota({required String lang}) async =>
|
||||
const CourseQuota(limit: 2, used: 0, remaining: 2);
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) =>
|
||||
throw UnimplementedError('홈 테스트는 남은 횟수만 쓴다');
|
||||
}
|
||||
|
||||
class _FakeLocationService implements LocationService {
|
||||
const _FakeLocationService({
|
||||
this.granted = true,
|
||||
@@ -218,6 +233,9 @@ Future<_FakeEventRepository> _pumpApp(
|
||||
locationServiceProvider.overrideWithValue(
|
||||
_FakeLocationService(granted: locationGranted, point: currentPoint),
|
||||
),
|
||||
aiCourseRepositoryProvider.overrideWithValue(
|
||||
const _FakeAiCourseRepository(),
|
||||
),
|
||||
clockProvider.overrideWithValue(() => _now),
|
||||
],
|
||||
child: const WanderGoApp(),
|
||||
@@ -242,6 +260,8 @@ Future<void> _useTallScreen(WidgetTester tester) async {
|
||||
|
||||
void main() {
|
||||
testWidgets('첫 실행: 동네를 고르면 홈으로 가고, 그 동네 기준 이번 주말 행사를 보인다', (tester) async {
|
||||
// 행사 칸은 'AI에게 코스 맡기기' 아래라 기본 화면 높이로는 보이지 않는다
|
||||
await _useTallScreen(tester);
|
||||
final repo = await _pumpApp(
|
||||
tester,
|
||||
repo: _FakeEventRepository(weekend: [_event('e1', '수성빛예술제')]),
|
||||
@@ -273,6 +293,7 @@ void main() {
|
||||
});
|
||||
|
||||
testWidgets('주말 행사가 없으면 다가오는 공연·행사(30일)로 바꿔 보인다', (tester) async {
|
||||
await _useTallScreen(tester);
|
||||
final repo = await _pumpApp(
|
||||
tester,
|
||||
prefs: {'onboarding_done': true},
|
||||
|
||||
Reference in New Issue
Block a user