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 _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 load({InsideSort sort = InsideSort.latest}) async => throw StateError('boom'); @override Future> 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 = []; final accepted = []; final sent = []; var list = [ InsideAnswer( id: 'a1', authorName: '남', body: '남의 답', createdAt: DateTime(2026, 9, 1), ), InsideAnswer( id: 'a2', authorName: '나', body: '내 답', createdAt: DateTime(2026, 9, 1), isMine: true, ), ]; @override Future 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> answers(String questionId) async => list; @override Future 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 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 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(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(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(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 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 minBounty() async => 200; }