Files
fanuzu/app/test/translation_test.dart

456 lines
16 KiB
Dart

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<void> 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<void> pump(WidgetTester tester, TranslationRepository repo) =>
pumpScreen(
tester,
InsideDetailScreen(
question: question,
answers: answers,
translation: repo,
),
);
Future<void> 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<TranslationCommentCard>(
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<TranslationCommentCard>(
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<OutlinedButton>(
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<TranslationCommentCard>(
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<TranslationCommentCard>(
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<OutlinedButton>(
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<TranslationException>().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<TranslationException>().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<TranslationException>().having(
(e) => e.kind,
'kind',
TranslationFailure.selfMark,
),
),
);
expect(
(await db.doc('translationEntries/e1/markings/bob').get()).exists,
isFalse,
);
});
});
}
/// 마킹은 성공하지만 스트림은 다시 오지 않는 저장소 — 낙관적 갱신을 본다.
class _SilentMarkRepository extends SampleTranslationRepository {
_SilentMarkRepository(List<SampleTranslation> seed)
: super(uid: 'me', seed: seed);
int marks = 0;
@override
Future<void> mark(TranslationEntry entry, {required bool valid}) async =>
marks++;
}
/// 고정 목록을 돌려주는 저장소.
class _FixedRepository extends SampleTranslationRepository {
_FixedRepository(this.rows) : super(uid: 'me');
final List<TranslationEntry> rows;
@override
Stream<List<TranslationEntry>> watch(
String rootPath, {
int limit = translationPageSize,
}) => Stream.value(rows);
}