Files
fanuzu/app/test/inside_station_test.dart

500 lines
18 KiB
Dart

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;
}