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,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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user