앱 단위·화면·통합 테스트
This commit is contained in:
@@ -0,0 +1,499 @@
|
||||
import 'package:fancake/core/data/backend.dart';
|
||||
import 'package:fancake/core/theme/app_theme.dart';
|
||||
import 'package:fancake/core/ui/app_icons.dart';
|
||||
import 'package:fancake/features/inside/inside_data.dart';
|
||||
import 'package:fancake/features/inside/inside_detail_screen.dart';
|
||||
import 'package:fancake/features/inside/inside_screen.dart';
|
||||
import 'package:fancake/features/inside/inside_write_screen.dart';
|
||||
import 'package:fancake/features/station/station_data.dart';
|
||||
import 'package:fancake/features/station/station_screen.dart';
|
||||
import 'package:fancake/features/station/station_suggest_screen.dart';
|
||||
import 'package:fancake/features/translation/translation_data.dart';
|
||||
import 'package:fancake/features/translation/translation_comments.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
Future<void> _pump(WidgetTester tester, Widget screen) async {
|
||||
tester.view.physicalSize = const Size(1080, 2400);
|
||||
tester.view.devicePixelRatio = 2;
|
||||
addTearDown(tester.view.reset);
|
||||
await tester.pumpWidget(MaterialApp(theme: buildAppTheme(), home: screen));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
/// 항상 실패하는 저장소. 화면의 오류 경로를 태운다.
|
||||
class _FailingInsideRepository extends SampleInsideRepository {
|
||||
@override
|
||||
Future<InsideSnapshot> load({InsideSort sort = InsideSort.latest}) async =>
|
||||
throw StateError('boom');
|
||||
|
||||
@override
|
||||
Future<List<InsideQuestion>> more({
|
||||
required InsideSort sort,
|
||||
int limit = 5,
|
||||
}) async => throw StateError('boom');
|
||||
}
|
||||
|
||||
/// 현상금 Q&A 경로를 기록하는 저장소. [failWith] 가 있으면 쓰기 호출이 그걸 던진다.
|
||||
class _QnaRepository extends SampleInsideRepository {
|
||||
_QnaRepository({this.failWith});
|
||||
|
||||
final BackendException? failWith;
|
||||
final created = <(String, String, int)>[];
|
||||
final seqs = <String>[];
|
||||
final accepted = <String>[];
|
||||
final sent = <String>[];
|
||||
var list = <InsideAnswer>[
|
||||
InsideAnswer(
|
||||
id: 'a1',
|
||||
authorName: '남',
|
||||
body: '남의 답',
|
||||
createdAt: DateTime(2026, 9, 1),
|
||||
),
|
||||
InsideAnswer(
|
||||
id: 'a2',
|
||||
authorName: '나',
|
||||
body: '내 답',
|
||||
createdAt: DateTime(2026, 9, 1),
|
||||
isMine: true,
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<String> create({
|
||||
required String title,
|
||||
required String body,
|
||||
required int bountyPop,
|
||||
required String clientSeq,
|
||||
}) async {
|
||||
seqs.add(clientSeq);
|
||||
if (failWith != null) throw failWith!;
|
||||
created.add((title, body, bountyPop));
|
||||
return 'q-new';
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<InsideAnswer>> answers(String questionId) async => list;
|
||||
|
||||
@override
|
||||
Future<void> answer(String questionId, String body) async {
|
||||
sent.add(body);
|
||||
list = [
|
||||
InsideAnswer(
|
||||
id: 'a3',
|
||||
authorName: '나',
|
||||
body: body,
|
||||
createdAt: DateTime(2026, 9, 2),
|
||||
isMine: true,
|
||||
),
|
||||
...list,
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> accept(String questionId, String answerId) async {
|
||||
if (failWith != null) throw failWith!;
|
||||
accepted.add(answerId);
|
||||
list = [
|
||||
for (final a in list)
|
||||
a.id == answerId
|
||||
? InsideAnswer(
|
||||
id: a.id,
|
||||
authorName: a.authorName,
|
||||
body: a.body,
|
||||
createdAt: a.createdAt,
|
||||
isAccepted: true,
|
||||
)
|
||||
: a,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyStationRepository implements StationRepository {
|
||||
@override
|
||||
Future<StationSnapshot> load() async =>
|
||||
const StationSnapshot(links: [], canSuggest: false);
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('INSIDE 목록 (F3 · 196:2673 · 199:43083 · 199:43964)', () {
|
||||
testWidgets('세 정렬 칩을 Figma 화면 이름 그대로 띄운다', (tester) async {
|
||||
await _pump(tester, const InsideScreen());
|
||||
// Figma 가 화면을 셋으로 나눠 둔 그 축이다. 라벨이 바뀌면 대조가 끊긴다.
|
||||
for (final label in ['최신순', '인기순', 'POP순']) {
|
||||
expect(find.text(label), findsOneWidget);
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('명세대로 최초 5건만 띄운다', (tester) async {
|
||||
await _pump(tester, const InsideScreen());
|
||||
// 2.70-1 — *최신순 소팅 최초 5개, 스크롤 시 5개씩 호출*
|
||||
expect(find.byType(InsideQuestionCard), findsNWidgets(5));
|
||||
});
|
||||
|
||||
testWidgets('기본 정렬은 최신순이다', (tester) async {
|
||||
await _pump(tester, const InsideScreen());
|
||||
final cards = tester
|
||||
.widgetList<InsideQuestionCard>(find.byType(InsideQuestionCard))
|
||||
.toList();
|
||||
for (var i = 1; i < cards.length; i++) {
|
||||
expect(
|
||||
cards[i - 1].question.createdAt.isAfter(cards[i].question.createdAt),
|
||||
isTrue,
|
||||
reason: '${cards[i - 1].question.id} 가 ${cards[i].question.id} 보다 뒤다',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('POP순을 고르면 현상금 내림차순으로 다시 그린다', (tester) async {
|
||||
await _pump(tester, const InsideScreen());
|
||||
await tester.tap(find.text('POP순'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final cards = tester
|
||||
.widgetList<InsideQuestionCard>(find.byType(InsideQuestionCard))
|
||||
.toList();
|
||||
expect(cards.first.question.bountyPop, 800);
|
||||
for (var i = 1; i < cards.length; i++) {
|
||||
expect(
|
||||
cards[i - 1].question.bountyPop >= cards[i].question.bountyPop,
|
||||
isTrue,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('현상금이 0이면 POP 칩을 그리지 않는다', (tester) async {
|
||||
// 0 P 칩이 붙으면 없는 상금을 광고하는 셈이다.
|
||||
await _pump(
|
||||
tester,
|
||||
Scaffold(
|
||||
body: InsideQuestionCard(
|
||||
question: InsideQuestion(
|
||||
id: 'z',
|
||||
title: '제목',
|
||||
body: '본문',
|
||||
authorName: '누군가',
|
||||
createdAt: DateTime(2026, 9, 1),
|
||||
),
|
||||
onTap: () {},
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(find.text('0 P'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('불러오기가 실패하면 목록 대신 다시 시도를 준다', (tester) async {
|
||||
await _pump(tester, InsideScreen(repository: _FailingInsideRepository()));
|
||||
expect(find.text('인사이드를 불러오지 못했습니다'), findsOneWidget);
|
||||
expect(find.text('다시 시도'), findsOneWidget);
|
||||
expect(find.byType(InsideQuestionCard), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('작성 플로팅 버튼이 있다', (tester) async {
|
||||
// 2.70-3 — *우측 하단 고정 · 해당 팬덤에 가입된 유저에게만 노출*
|
||||
await _pump(tester, const InsideScreen());
|
||||
expect(find.text('인사이드 작성'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
group('INSIDE 상세 (F3 · 207:53130 본인 · 201:49678 타인)', () {
|
||||
InsideQuestion question({bool isMine = false}) => InsideQuestion(
|
||||
id: 'q',
|
||||
title: '제목',
|
||||
body: '본문',
|
||||
authorName: '별빛수집가',
|
||||
createdAt: DateTime(2026, 9, 1, 20),
|
||||
bountyPop: 500,
|
||||
answerCount: 3,
|
||||
isMine: isMine,
|
||||
);
|
||||
|
||||
testWidgets('타인 글은 신고·차단을 준다', (tester) async {
|
||||
await _pump(tester, InsideDetailScreen(question: question()));
|
||||
await tester.tap(find.byTooltip('더보기'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('신고하기'), findsOneWidget);
|
||||
expect(find.text('차단하기'), findsOneWidget);
|
||||
expect(find.text('삭제하기'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('번역 저장소가 있으면 별도 영역 없이 댓글 입력에 지구본(번역 댓글)을 붙인다', (tester) async {
|
||||
await _pump(
|
||||
tester,
|
||||
InsideDetailScreen(
|
||||
question: question(),
|
||||
translation: SampleTranslationRepository(),
|
||||
),
|
||||
);
|
||||
final bar = tester.widget<CommentInputBar>(find.byType(CommentInputBar));
|
||||
expect(bar.rootPath, 'questions/q');
|
||||
expect(bar.translation, isNotNull);
|
||||
expect(find.bySemanticsLabel('번역 댓글 작성'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('본인 글은 수정·삭제를 준다', (tester) async {
|
||||
await _pump(tester, InsideDetailScreen(question: question(isMine: true)));
|
||||
await tester.tap(find.byTooltip('더보기'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('수정하기'), findsOneWidget);
|
||||
expect(find.text('삭제하기'), findsOneWidget);
|
||||
// 본인 글을 본인이 신고하는 경로가 열려 있으면 안 된다.
|
||||
expect(find.text('신고하기'), findsNothing);
|
||||
});
|
||||
});
|
||||
|
||||
group('INSIDE 작성 (F3 · 199:44733)', () {
|
||||
testWidgets('제목과 내용이 비면 등록되지 않는다', (tester) async {
|
||||
await _pump(tester, const InsideWriteScreen());
|
||||
await tester.tap(find.text('완료'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('제목을 입력해 주세요.'), findsOneWidget);
|
||||
expect(find.text('내용을 입력해 주세요.'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('현상금이 보유 POP 을 넘으면 막는다', (tester) async {
|
||||
await _pump(tester, const InsideWriteScreen(myPop: 1000));
|
||||
await tester.enterText(find.byType(TextFormField).at(0), '제목');
|
||||
await tester.enterText(find.byType(TextFormField).at(1), '내용');
|
||||
await tester.enterText(find.byType(TextField).at(2), '2000');
|
||||
await tester.tap(find.text('완료'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('보유 POP 을 넘을 수 없습니다.'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('현상금은 사본 안내대로 100 POP 이상이어야 한다', (tester) async {
|
||||
await _pump(tester, const InsideWriteScreen(myPop: 1000));
|
||||
await tester.enterText(find.byType(TextFormField).at(0), '제목');
|
||||
await tester.enterText(find.byType(TextFormField).at(1), '내용');
|
||||
await tester.enterText(find.byType(TextField).at(2), '99');
|
||||
await tester.tap(find.text('완료'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('현상금은 100 POP 이상이어야 합니다.'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
group('INSIDE 현상금 하한 운영값', () {
|
||||
testWidgets('하한은 저장소(config/policy.qnaMinBountyPop)를 따른다', (tester) async {
|
||||
await _pump(
|
||||
tester,
|
||||
InsideWriteScreen(repository: _MinBounty(), myPop: 1000),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(find.byType(TextFormField).at(0), '제목');
|
||||
await tester.enterText(find.byType(TextFormField).at(1), '내용');
|
||||
await tester.enterText(find.byType(TextField).at(2), '150');
|
||||
await tester.tap(find.text('완료'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('현상금은 200 POP 이상이어야 합니다.'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
group('INSIDE 현상금 Q&A (별첨 1 ④-2)', () {
|
||||
InsideQuestion mine() => InsideQuestion(
|
||||
id: 'q',
|
||||
title: '제목',
|
||||
body: '본문',
|
||||
authorName: '나',
|
||||
createdAt: DateTime(2026, 9, 1),
|
||||
bountyPop: 300,
|
||||
isMine: true,
|
||||
);
|
||||
|
||||
Future<void> fill(WidgetTester tester, String bounty) async {
|
||||
await tester.enterText(find.byType(TextFormField).at(0), ' 제목 ');
|
||||
await tester.enterText(find.byType(TextFormField).at(1), '내용');
|
||||
await tester.enterText(find.byType(TextField).at(2), bounty);
|
||||
await tester.tap(find.text('완료'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('현상금을 비우면 등록되지 않는다', (tester) async {
|
||||
final repo = _QnaRepository();
|
||||
await _pump(tester, InsideWriteScreen(repository: repo, myPop: 1000));
|
||||
await fill(tester, '');
|
||||
expect(find.text('현상금을 입력해 주세요.'), findsOneWidget);
|
||||
expect(repo.created, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('완료하면 fn-question-create 로 예치 요청을 보낸다', (tester) async {
|
||||
final repo = _QnaRepository();
|
||||
await _pump(tester, InsideWriteScreen(repository: repo, myPop: 1000));
|
||||
await fill(tester, '300');
|
||||
expect(repo.created, [('제목', '내용', 300)]);
|
||||
});
|
||||
|
||||
testWidgets('잔액 부족(E-POP-01)은 한국어 안내로 보인다', (tester) async {
|
||||
final repo = _QnaRepository(
|
||||
failWith: const BackendException('E-POP-01', 'POP 잔액이 부족합니다.'),
|
||||
);
|
||||
await _pump(tester, InsideWriteScreen(repository: repo, myPop: 1000));
|
||||
await fill(tester, '300');
|
||||
expect(find.text('POP 이 모자라 현상금을 걸지 못했습니다.'), findsOneWidget);
|
||||
// 입력을 바꿔 다시 보내도 같은 화면에서는 같은 번호다 — 이중 예치 방지.
|
||||
await tester.enterText(find.byType(TextField).at(2), '400');
|
||||
await tester.tap(find.text('완료'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(repo.seqs, hasLength(2));
|
||||
expect(repo.seqs.toSet(), hasLength(1));
|
||||
});
|
||||
|
||||
testWidgets('환불 중단 상태는 중립 문구로 보인다', (tester) async {
|
||||
final q = InsideQuestion(
|
||||
id: 'q',
|
||||
title: '제목',
|
||||
body: '본문',
|
||||
authorName: '나',
|
||||
createdAt: DateTime(2026, 9, 1),
|
||||
isMine: true,
|
||||
refund: InsideRefund.fromStatus('refund_failed'),
|
||||
);
|
||||
await _pump(
|
||||
tester,
|
||||
InsideDetailScreen(question: q, repository: _QnaRepository()),
|
||||
);
|
||||
expect(find.text('채택 기한이 지나 현상금 환불을 확인하고 있습니다.'), findsOneWidget);
|
||||
expect(find.text('채택하기'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('질문자는 남의 답변만 채택할 수 있고, 채택 뒤에는 버튼이 사라진다', (tester) async {
|
||||
final repo = _QnaRepository();
|
||||
await _pump(
|
||||
tester,
|
||||
InsideDetailScreen(question: mine(), repository: repo),
|
||||
);
|
||||
expect(find.text('남의 답'), findsOneWidget);
|
||||
// 자기 답변에는 채택하기가 없다(E-QNA-01).
|
||||
expect(find.text('채택하기'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('채택하기'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('채택'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(repo.accepted, ['a1']);
|
||||
expect(find.text('채택 완료'), findsOneWidget);
|
||||
expect(find.text('채택하기'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('타인 질문에는 채택하기가 없다', (tester) async {
|
||||
final q = InsideQuestion(
|
||||
id: 'q',
|
||||
title: '제목',
|
||||
body: '본문',
|
||||
authorName: '남',
|
||||
createdAt: DateTime(2026, 9, 1),
|
||||
);
|
||||
await _pump(
|
||||
tester,
|
||||
InsideDetailScreen(question: q, repository: _QnaRepository()),
|
||||
);
|
||||
expect(find.text('채택하기'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('기한이 지난 채택은 E-QNA-02 안내를 띄운다', (tester) async {
|
||||
final repo = _QnaRepository(
|
||||
failWith: const BackendException(
|
||||
'E-QNA-02',
|
||||
'채택 기한이 지나 현상금이 환불될 예정입니다.',
|
||||
),
|
||||
);
|
||||
await _pump(
|
||||
tester,
|
||||
InsideDetailScreen(question: mine(), repository: repo),
|
||||
);
|
||||
await tester.tap(find.text('채택하기'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('채택'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('채택 기한이 지나 현상금이 환불될 예정입니다.'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('댓글을 보내면 저장 뒤 목록에 보인다', (tester) async {
|
||||
final repo = _QnaRepository();
|
||||
await _pump(
|
||||
tester,
|
||||
InsideDetailScreen(question: mine(), repository: repo),
|
||||
);
|
||||
await tester.enterText(find.byType(TextField), '새 답변');
|
||||
await tester.tap(
|
||||
find.byWidgetPredicate((w) => w is Image && w.image == AppIcons.send),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(repo.sent, ['새 답변']);
|
||||
expect(find.text('새 답변'), findsOneWidget);
|
||||
});
|
||||
|
||||
test('서버 코드를 화면 문구로 옮긴다', () {
|
||||
expect(
|
||||
insideErrorMessage(const BackendException('E-QNA-01', 'x')),
|
||||
'자기 답변은 채택할 수 없습니다.',
|
||||
);
|
||||
expect(
|
||||
insideErrorMessage(const BackendException('E-QNA-03', 'x')),
|
||||
'이미 채택한 질문입니다. 채택은 바꿀 수 없습니다.',
|
||||
);
|
||||
expect(
|
||||
insideErrorMessage(const BackendException('E-SYS-01', '서버 문구')),
|
||||
'서버 문구',
|
||||
);
|
||||
expect(insideErrorMessage(StateError('x')), '연결을 확인하고 다시 시도해 주세요.');
|
||||
});
|
||||
});
|
||||
|
||||
group('STATION 허브 (S-S-HUB · Figma 시안 없음)', () {
|
||||
testWidgets('분류 칩과 링크를 띄운다', (tester) async {
|
||||
await _pump(tester, const StationScreen());
|
||||
expect(find.text('전체'), findsOneWidget);
|
||||
for (final c in StationCategory.values) {
|
||||
expect(find.text(c.label), findsWidgets);
|
||||
}
|
||||
expect(find.byType(StationLinkTile), findsNWidgets(4));
|
||||
});
|
||||
|
||||
testWidgets('분류를 고르면 그 분류만 남는다', (tester) async {
|
||||
await _pump(tester, const StationScreen());
|
||||
await tester.tap(find.widgetWithText(ChoiceChip, '예매'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(StationLinkTile), findsOneWidget);
|
||||
expect(find.text('팬미팅 예매'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('링크가 없으면 가입 안내를 띄우고 제안 버튼을 감춘다', (tester) async {
|
||||
await _pump(tester, StationScreen(repository: _EmptyStationRepository()));
|
||||
expect(find.textContaining('아직 등록된 링크가 없습니다'), findsOneWidget);
|
||||
expect(find.text('판매처 제안'), findsNothing);
|
||||
});
|
||||
});
|
||||
|
||||
group('STATION 제안 (S-S-SUGGEST)', () {
|
||||
testWidgets('https 가 아닌 주소를 막는다', (tester) async {
|
||||
await _pump(tester, const StationSuggestScreen());
|
||||
await tester.enterText(find.byType(TextFormField).at(0), '어떤 판매처');
|
||||
await tester.enterText(
|
||||
find.byType(TextFormField).at(1),
|
||||
'http://shop.example',
|
||||
);
|
||||
await tester.tap(find.text('제출'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('https 로 시작하는 주소를 입력해 주세요.'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('https 주소는 통과한다', (tester) async {
|
||||
await _pump(tester, const StationSuggestScreen());
|
||||
await tester.enterText(find.byType(TextFormField).at(0), '어떤 판매처');
|
||||
await tester.enterText(
|
||||
find.byType(TextFormField).at(1),
|
||||
'https://shop.example',
|
||||
);
|
||||
await tester.tap(find.text('제출'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('https 로 시작하는 주소를 입력해 주세요.'), findsNothing);
|
||||
expect(find.textContaining('서버 연동 후 열립니다'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
class _MinBounty extends SampleInsideRepository {
|
||||
@override
|
||||
Future<int> minBounty() async => 200;
|
||||
}
|
||||
Reference in New Issue
Block a user