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:
2026-11-12 15:48:23 +09:00
parent 8e53974c3f
commit af1a0f26fc
44 changed files with 7864 additions and 19 deletions
@@ -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);
});
}