import 'package:fake_cloud_firestore/fake_cloud_firestore.dart'; import 'package:fancake/core/data/backend.dart'; import 'package:fancake/core/i18n/app_locale.dart'; import 'package:fancake/core/ui/app_icons.dart'; import 'package:fancake/features/translation/translation_data.dart'; import 'package:fancake/features/campaign/campaign_detail_screen.dart'; import 'package:fancake/features/inside/inside_data.dart'; import 'package:fancake/features/inside/inside_detail_screen.dart'; import 'package:fancake/features/planet/lounge_post_screens.dart'; import 'package:fancake/features/planet/planet_data.dart'; import 'package:fancake/features/translation/translation_comments.dart'; import 'package:firebase_auth_mocks/firebase_auth_mocks.dart'; import 'package:flutter/material.dart'; import 'package:fancake/core/theme/app_theme.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; /// 참여형 번역 — FR-I18N-02 · 원장 B-63 · E-TRN-01. /// 화면에서는 댓글 영역의 「번역 댓글」이다(Figma `201:50554` · `540:19136`). void main() { const target = 'questions/q1'; final question = InsideQuestion( id: 'q1', authorName: '다이브짱', title: '공방 신청 꿀팁', body: '본문', createdAt: DateTime(2026, 9, 1), ); final answers = [ InsideAnswer( id: 'a1', authorName: 'pro_따봉', body: '세상이 이런 꿀팁이', createdAt: DateTime(2026, 9, 1), ), ]; Future pumpScreen(WidgetTester tester, Widget screen) async { tester.view.physicalSize = const Size(1080, 2400); tester.view.devicePixelRatio = 2; addTearDown(tester.view.reset); await tester.pumpWidget( MaterialApp( // 실제 앱 테마 — 테마의 버튼 최소 크기가 마킹 버튼을 가린 적이 있다(10/5 실기기). theme: buildAppTheme(), locale: const Locale('ko'), supportedLocales: appLocales, localizationsDelegates: const [ RemoteL10nDelegate({}), GlobalMaterialLocalizations.delegate, GlobalWidgetsLocalizations.delegate, GlobalCupertinoLocalizations.delegate, ], home: screen, ), ); await tester.pumpAndSettle(); } Future pump(WidgetTester tester, TranslationRepository repo) => pumpScreen( tester, InsideDetailScreen( question: question, answers: answers, translation: repo, ), ); Future send(WidgetTester tester, String body) async { await tester.enterText(find.byType(TextField), body); await tester.tap(find.byTooltip('보내기')); await tester.pumpAndSettle(); } testWidgets('지구본으로 쓴 번역 댓글은 질문 본문의 번역으로 댓글 목록에 뜨고, 본인 번역은 마킹이 거절된다', ( tester, ) async { final repo = SampleTranslationRepository(uid: 'me'); await pump(tester, repo); expect(find.byType(TranslationCommentCard), findsNothing); await tester.tap( find.descendant( of: find.byType(CommentInputBar), matching: find.bySemanticsLabel('번역 댓글 작성'), ), ); await tester.pumpAndSettle(); expect(find.text('번역을 입력하세요'), findsOneWidget); await send(tester, ' Great tip '); final card = tester.widget( find.byType(TranslationCommentCard), ); expect(card.entry.targetPath, target); expect(card.reply, isFalse); expect(find.text('번역함'), findsOneWidget); expect(find.text('한국어'), findsOneWidget); expect(find.text('Great tip'), findsOneWidget); expect(find.textContaining('24시간'), findsOneWidget); // 등록 뒤 입력은 일반 댓글로 돌아간다. expect(find.text('댓글을 입력해 보세요.'), findsOneWidget); await tester.tap(find.text('유효 0')); await tester.pumpAndSettle(); expect(find.text('본인 번역은 마킹할 수 없습니다.'), findsOneWidget); expect(find.text('유효 0'), findsOneWidget); }); testWidgets('댓글의 대댓글 버튼으로 고르면 그 댓글의 번역이 대댓글 자리에 붙는다', (tester) async { final repo = SampleTranslationRepository(uid: 'me'); await pump(tester, repo); // 대댓글 버튼(시안의 말풍선)은 이 댓글을 번역 대상으로 고른다. await tester.tap( find.byWidgetPredicate( (w) => w is ImageIcon && w.image == AppIcons.messageCircle && w.size == 16, ), ); await tester.pumpAndSettle(); expect(find.text('pro_따봉 님 댓글 번역'), findsOneWidget); await send(tester, 'Is there really such a great tip'); final card = tester.widget( find.byType(TranslationCommentCard), ); expect(card.entry.targetPath, '$target/answers/a1'); expect(card.reply, isTrue); expect(find.text('pro_따봉 님 댓글 번역'), findsNothing); }); testWidgets('다른 멤버의 번역은 한 번만 마킹할 수 있다', (tester) async { final repo = SampleTranslationRepository( uid: 'me', seed: [ SampleTranslation( id: 't1', targetPath: target, locale: 'en', authorUid: 'alice', authorName: '호떡이', body: '번역문', ), ], ); await pump(tester, repo); expect(find.text('호떡이'), findsOneWidget); expect(find.text('English'), findsOneWidget); await tester.tap(find.text('유효 0')); await tester.pumpAndSettle(); expect(find.text('유효 1'), findsOneWidget); expect(find.text('마킹했습니다.'), findsOneWidget); // 마킹 뒤에는 두 버튼이 잠긴다. final invalid = tester.widget( find.widgetWithText(OutlinedButton, '무효 0'), ); expect(invalid.onPressed, isNull); }); testWidgets('라운지 글 상세 — 댓글의 지구본으로 그 댓글을 번역한다(Q-TR-06 B안)', (tester) async { final repo = SampleTranslationRepository(uid: 'me'); final post = LoungePost( id: 'tr-${DateTime.now().microsecondsSinceEpoch}', authorName: '팬', createdAt: DateTime(2026, 9, 1), title: '', body: '본문', likes: 0, comments: 0, ); await pumpScreen(tester, LoungePostScreen(post: post, translation: repo)); await send(tester, '반가워요'); expect(find.text('반가워요'), findsOneWidget); await tester.tap(find.byTooltip('번역 댓글 작성')); await tester.pumpAndSettle(); expect(find.textContaining('님 댓글 번역'), findsOneWidget); await send(tester, 'Nice to meet you'); final card = tester.widget( find.byType(TranslationCommentCard), ); expect(card.entry.targetPath, startsWith('posts/${post.id}/comments/')); expect(card.reply, isTrue); }); testWidgets('캠페인 응원 게시판 — 기본은 일반 댓글, 지구본을 켜면 캠페인 본문의 번역 댓글', (tester) async { final repo = SampleTranslationRepository(uid: 'me'); await pumpScreen( tester, CampaignDetailScreen(campaignId: 'c1', myPop: 0, translation: repo), ); expect(find.byType(CommentInputBar), findsNothing); await tester.tap(find.textContaining('응원 게시판')); await tester.pumpAndSettle(); expect(find.text('댓글을 입력해 보세요.'), findsOneWidget); await send(tester, '화이팅'); expect(find.text('화이팅'), findsOneWidget); expect(find.byType(TranslationCommentCard), findsNothing); await tester.tap( find.descendant( of: find.byType(CommentInputBar), matching: find.bySemanticsLabel('번역 댓글 작성'), ), ); await tester.pumpAndSettle(); expect(find.text('번역을 입력하세요'), findsOneWidget); await send(tester, 'Go go'); final card = tester.widget( find.byType(TranslationCommentCard), ); expect(card.entry.targetPath, 'campaigns/c1'); }); testWidgets('마킹이 성공하면 스트림을 기다리지 않고 수와 잠금이 바로 바뀐다', (tester) async { final repo = _SilentMarkRepository([ SampleTranslation( id: 't1', targetPath: target, locale: 'en', authorUid: 'alice', body: '번역문', ), ]); await pump(tester, repo); // 작성자 이름이 비어 있으면 「팬」. expect(find.text('팬'), findsOneWidget); // 두 버튼이 같은 줄에 보이는 크기로 있어야 누를 수 있다. final validBox = tester.getRect( find.widgetWithText(OutlinedButton, '유효 0'), ); final invalidBox = tester.getRect( find.widgetWithText(OutlinedButton, '무효 0'), ); expect(invalidBox.width, greaterThan(0)); expect(validBox.top, invalidBox.top); await tester.tap(find.text('무효 0')); await tester.pumpAndSettle(); expect(repo.marks, 1); expect(find.text('무효 1'), findsOneWidget); final valid = tester.widget( find.widgetWithText(OutlinedButton, '유효 0'), ); expect(valid.onPressed, isNull); }); testWidgets('정산이 끝난 번역은 마킹 버튼을 숨긴다', (tester) async { final repo = _FixedRepository([ const TranslationEntry( id: 'done', authorUid: 'alice', authorName: '호떡이', body: 'Settled', locale: 'en', targetPath: target, status: 'confirmed', ), ]); await pump(tester, repo); expect(find.text('Settled'), findsOneWidget); expect(find.byType(OutlinedButton), findsNothing); }); testWidgets('한 페이지(50건)가 차면 「더 보기」로 다음 페이지를 붙인다', (tester) async { final repo = SampleTranslationRepository( uid: 'me', seed: [ for (var i = 0; i < translationPageSize + 1; i++) SampleTranslation( id: 'p$i', targetPath: target, locale: 'en', authorUid: 'u$i', body: 'row $i', ), ], ); await pump(tester, repo); expect(find.byType(TranslationCommentCard), findsNWidgets(50)); await tester.ensureVisible(find.text('더 보기')); await tester.pumpAndSettle(); await tester.tap(find.text('더 보기')); await tester.pumpAndSettle(); expect(find.byType(TranslationCommentCard), findsNWidgets(51)); expect(find.text('더 보기'), findsNothing); }); test('글 경로로 본문과 댓글의 번역을 모두 읽는다', () async { final repo = SampleTranslationRepository(uid: 'me'); await repo.submit(target, 'en', 'Hi'); await repo.submit('$target/answers/a1', 'en', 'Yo'); await repo.submit('questions/q2', 'en', 'Other'); final rows = await repo.watch(target).first; // 최신순. expect(rows.map((e) => e.targetPath), ['$target/answers/a1', target]); expect(translationRootOf('posts/p1/comments/c1'), 'posts/p1'); }); test('같은 콘텐츠·언어 재등록은 duplicate 로 거절한다', () async { final repo = SampleTranslationRepository(uid: 'me'); await repo.submit(target, 'en', 'Hi'); await expectLater( repo.submit(target, 'en', 'Hi again'), throwsA( isA().having( (e) => e.kind, 'kind', TranslationFailure.duplicate, ), ), ); }); group('Firestore 저장소', () { late FakeFirebaseFirestore db; late FirestoreTranslationRepository repo; setUp(() async { db = FakeFirebaseFirestore(); final auth = MockFirebaseAuth( signedIn: true, mockUser: MockUser(uid: 'bob'), ); repo = FirestoreTranslationRepository(Backend(firestore: db, auth: auth)); await db.doc('translationEntries/e1').set({ // 수는 fn-translation-mark 가 번역 문서에 올린 값이다. 'validCount': 0, 'invalidCount': 1, 'authorUid': 'alice', 'targetPath': target, 'rootPath': target, 'authorName': '호떡이', 'locale': 'en', 'body': 'Hello', 'status': 'pending', 'createdAt': DateTime(2026, 9, 28), }); // 작성자 자기 마킹은 규칙이 막지만, 들어와 있어도 세지 않는다(fn-translation-settle 과 같음). await db.doc('translationEntries/e1/markings/alice').set({'valid': true}); await db.doc('translationEntries/e1/markings/carol').set({ 'valid': false, }); }); test('수는 번역 문서에서 읽고 내 마킹만 한 건 읽는다', () async { final rows = await repo.watch(target).first; expect(rows.single.authorName, '호떡이'); expect(rows.single.targetPath, target); expect(rows.single.validCount, 0); expect(rows.single.invalidCount, 1); expect(rows.single.myMark, isNull); }); test('이미 마킹한 번역은 서버에 닿기 전에 거절한다', () async { await db.doc('translationEntries/e1/markings/bob').set({'valid': true}); final entry = (await repo.watch(target).first).single; expect(entry.myMark, isTrue); await expectLater( repo.mark(entry, valid: false), throwsA( isA().having( (e) => e.kind, 'kind', TranslationFailure.alreadyMarked, ), ), ); }); test('서버 오류 코드 E-TRN-01~05 를 화면 실패 종류로 옮긴다', () { expect( translationFailureOf('E-TRN-01', forMark: true), TranslationFailure.selfMark, ); expect( translationFailureOf('E-TRN-02', forMark: false), TranslationFailure.dailyCap, ); expect( translationFailureOf('E-TRN-03', forMark: false), TranslationFailure.invalidTarget, ); expect( translationFailureOf('E-TRN-04', forMark: true), TranslationFailure.notEligible, ); expect( translationFailureOf('E-TRN-05', forMark: true), TranslationFailure.alreadyMarked, ); expect( translationFailureOf('E-TRN-05', forMark: false), TranslationFailure.duplicate, ); expect( translationFailureOf('E-SYS-01', forMark: false), TranslationFailure.other, ); }); test('본인 번역 마킹은 서버에 닿기 전에 거절한다(E-TRN-01)', () async { const own = TranslationEntry( id: 'e1', authorUid: 'bob', body: 'x', locale: 'en', ); await expectLater( repo.mark(own, valid: true), throwsA( isA().having( (e) => e.kind, 'kind', TranslationFailure.selfMark, ), ), ); expect( (await db.doc('translationEntries/e1/markings/bob').get()).exists, isFalse, ); }); }); } /// 마킹은 성공하지만 스트림은 다시 오지 않는 저장소 — 낙관적 갱신을 본다. class _SilentMarkRepository extends SampleTranslationRepository { _SilentMarkRepository(List seed) : super(uid: 'me', seed: seed); int marks = 0; @override Future mark(TranslationEntry entry, {required bool valid}) async => marks++; } /// 고정 목록을 돌려주는 저장소. class _FixedRepository extends SampleTranslationRepository { _FixedRepository(this.rows) : super(uid: 'me'); final List rows; @override Stream> watch( String rootPath, { int limit = translationPageSize, }) => Stream.value(rows); }