444 lines
15 KiB
Dart
444 lines
15 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: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(
|
|
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);
|
|
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);
|
|
}
|