앱 단위·화면·통합 테스트
This commit is contained in:
@@ -0,0 +1,328 @@
|
||||
@Tags(['design'])
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:fancake/core/data/repositories.dart';
|
||||
import 'package:fancake/core/router/app_router.dart';
|
||||
import 'package:fancake/core/theme/app_theme.dart';
|
||||
import 'package:fancake/features/auth/auth_repository.dart';
|
||||
import 'package:fancake/features/auth/login_screen.dart';
|
||||
import 'package:fancake/features/auth/signup_screens.dart';
|
||||
import 'package:fancake/features/inside/inside_data.dart';
|
||||
import 'package:fancake/features/inside/inside_detail_screen.dart';
|
||||
import 'package:fancake/features/inside/inside_search_screen.dart';
|
||||
import 'package:fancake/features/inside/inside_write_screen.dart';
|
||||
import 'package:fancake/features/notification/notification_screen.dart';
|
||||
import 'package:fancake/features/planet/moderation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
/// 디자인 일치 2단계 그룹 E — INSIDE(F9) · 로그인(F10) · 알림(F0-02).
|
||||
///
|
||||
/// 러너는 `design_match_test.dart` 와 같고, 화면 목록과 표본만 이 그룹 것이다.
|
||||
/// 실행: `DESIGN_MATCH_OUT=<폴더> fvm flutter test --tags design test/design_match/e_test.dart`
|
||||
|
||||
/// Figma 프레임 폭. 높이는 화면마다 Figma 프레임 높이를 따른다.
|
||||
const _width = 375.0;
|
||||
|
||||
/// iPhone X 계열 Figma 프레임의 상태 표시줄(48) · 홈 인디케이터(34).
|
||||
const _padding = FakeViewPadding(top: 48 * 2, bottom: 34 * 2);
|
||||
|
||||
typedef Build = Future<Widget> Function(Repositories r);
|
||||
typedef After = Future<void> Function(WidgetTester t);
|
||||
|
||||
class Shot {
|
||||
const Shot(this.id, {this.build, this.tab, this.h = 812, this.after});
|
||||
final String id;
|
||||
|
||||
/// 탭 밖 화면. [tab] 과 둘 중 하나만 준다.
|
||||
final Build? build;
|
||||
|
||||
/// 탭 루트 화면 — 실제 셸(하단 탭 포함)을 라우터로 띄운다.
|
||||
final String? tab;
|
||||
final double h;
|
||||
|
||||
/// 시트·다이얼로그·탭 전환처럼 화면을 띄운 뒤 한 번 더 조작해야 하는 상태.
|
||||
final After? after;
|
||||
}
|
||||
|
||||
Future<void> _tap(WidgetTester t, String text) async {
|
||||
await t.tap(find.text(text).first, warnIfMissed: false);
|
||||
await _settle(t);
|
||||
}
|
||||
|
||||
/// 표본 저장소는 Future.value · 짧은 delay 만 쓴다. 무한 애니메이션이 있어 pumpAndSettle 은 쓰지 않는다.
|
||||
Future<void> _settle(WidgetTester t) async {
|
||||
for (var i = 0; i < 6; i++) {
|
||||
await t.pump(const Duration(milliseconds: 300));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _overlay(
|
||||
WidgetTester t,
|
||||
void Function(BuildContext c) open,
|
||||
) async {
|
||||
open(t.element(find.byType(Scaffold).first));
|
||||
await _settle(t);
|
||||
}
|
||||
|
||||
/// 시안 알림(229:11265) 표본. 날짜 머리글이 「오늘 · 어제 · 과거」로 갈리도록 지금 기준으로 잡는다.
|
||||
List<AppNotification> _notifications() {
|
||||
final now = DateTime.now();
|
||||
DateTime at(int daysAgo, int h, int m) =>
|
||||
DateTime(now.year, now.month, now.day - daysAgo, h, m);
|
||||
return [
|
||||
AppNotification(
|
||||
id: '1',
|
||||
kind: NotificationKind.comment,
|
||||
title: '내 INSIDE에 새로운 댓글이 달렸어요.',
|
||||
at: at(0, 10, 40),
|
||||
source: 'INSIDE',
|
||||
),
|
||||
AppNotification(
|
||||
id: '2',
|
||||
kind: NotificationKind.comment,
|
||||
title: '내 캠페인에 새로운 댓글이 달렸어요.',
|
||||
at: at(0, 10, 40),
|
||||
source: 'CAMPAIGN',
|
||||
),
|
||||
AppNotification(
|
||||
id: '3',
|
||||
kind: NotificationKind.like,
|
||||
title: '내 INSIDE에 좋아요가 추가됐어요.',
|
||||
at: at(0, 9, 28),
|
||||
source: 'INSIDE',
|
||||
),
|
||||
AppNotification(
|
||||
id: '4',
|
||||
kind: NotificationKind.eclipse,
|
||||
title: '내 행성의 이클립스를 방어했어요.',
|
||||
at: at(1, 10, 40),
|
||||
source: 'BTS',
|
||||
),
|
||||
AppNotification(
|
||||
id: '5',
|
||||
kind: NotificationKind.eclipse,
|
||||
title: '내 행성의 이클립스가 발생했어요.',
|
||||
at: at(1, 10, 40),
|
||||
source: 'BTS',
|
||||
),
|
||||
AppNotification(
|
||||
id: '6',
|
||||
kind: NotificationKind.itemUsed,
|
||||
title: '팜하니짱님의 고귀한 기여로 영웅의 선물이 발동되었습니다!',
|
||||
at: at(2, 16, 27),
|
||||
source: '아이템',
|
||||
),
|
||||
AppNotification(
|
||||
id: '7',
|
||||
kind: NotificationKind.quest,
|
||||
title: '[만보기] 퀘스트를 완료했어요!',
|
||||
at: at(2, 13, 27),
|
||||
source: '퀘스트',
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
Future<InsideQuestion> _question(Repositories r, String id) async =>
|
||||
(await r.inside.load()).questions.firstWhere((q) => q.id == id);
|
||||
|
||||
final shots = <Shot>[
|
||||
Shot(
|
||||
'F0-02',
|
||||
build: (r) async => NotificationScreen(
|
||||
repository: SampleNotificationRepository(items: _notifications()),
|
||||
),
|
||||
),
|
||||
// F9 INSIDE
|
||||
Shot('F9-01', build: (r) async => InsideSearchScreen(repository: r.inside)),
|
||||
Shot(
|
||||
'F9-02',
|
||||
build: (r) async => InsideSearchScreen(repository: r.inside),
|
||||
after: (t) async {
|
||||
await t.enterText(find.byType(TextField), 'BTS');
|
||||
await t.testTextInput.receiveAction(TextInputAction.search);
|
||||
await _settle(t);
|
||||
await _tap(t, 'POP순');
|
||||
// 커서 깜빡임을 없앤다.
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
await _settle(t);
|
||||
},
|
||||
),
|
||||
Shot(
|
||||
'F9-03',
|
||||
h: 1360,
|
||||
build: (r) async => InsideDetailScreen(
|
||||
question: await _question(r, 'q-1'),
|
||||
answers: sampleInsideAnswers,
|
||||
),
|
||||
),
|
||||
Shot(
|
||||
'F9-04',
|
||||
build: (r) async => const ReportScreen(
|
||||
target: ModerationTarget(
|
||||
authorName: 'BTS_Hong',
|
||||
title: '공방 신청 꿀팁 공유합니다.',
|
||||
),
|
||||
),
|
||||
),
|
||||
Shot(
|
||||
'F9-05',
|
||||
build: (r) async => InsideDetailScreen(
|
||||
question: await _question(r, 'q-1'),
|
||||
answers: sampleInsideAnswers,
|
||||
),
|
||||
after: (t) => _overlay(t, (c) => confirmBlock(c, '다이브짱')),
|
||||
),
|
||||
Shot('F9-06', build: (r) async => const InsideWriteScreen(myPop: 12500)),
|
||||
Shot('F9-07', tab: '/inside', after: (t) => _tap(t, 'POP순')),
|
||||
Shot('F9-08', tab: '/inside', after: (t) => _tap(t, '인기순')),
|
||||
Shot(
|
||||
'F9-09',
|
||||
h: 1301,
|
||||
build: (r) async => InsideDetailScreen(question: await _question(r, 'q-4')),
|
||||
),
|
||||
Shot('F9-10', tab: '/inside'),
|
||||
// F10 로그인
|
||||
Shot('F10-01', build: (r) async => LoginScreen(auth: AuthRepository())),
|
||||
Shot('F10-02', build: (r) async => SimpleLoginScreen(auth: AuthRepository())),
|
||||
Shot(
|
||||
'F10-12',
|
||||
build: (r) async => SimpleLoginScreen(auth: AuthRepository(), signUp: true),
|
||||
),
|
||||
Shot('F10-15', h: 906, build: (r) async => const TermsScreen()),
|
||||
Shot('F10-16', build: (r) async => const SignupCompleteScreen()),
|
||||
];
|
||||
|
||||
Future<void> _loadFonts() async {
|
||||
final manifest =
|
||||
json.decode(await rootBundle.loadString('FontManifest.json')) as List;
|
||||
for (final font in manifest.cast<Map<String, dynamic>>()) {
|
||||
final loader = FontLoader(font['family'] as String);
|
||||
for (final f in (font['fonts'] as List).cast<Map<String, dynamic>>()) {
|
||||
loader.addFont(rootBundle.load(Uri.decodeFull(f['asset'] as String)));
|
||||
}
|
||||
await loader.load();
|
||||
}
|
||||
// 글꼴을 지정하지 않은 글자는 테스트 기본(Ahem 네모)으로 그려진다. 기기에서는 시스템 글꼴이므로 본문 글꼴로 대신한다.
|
||||
await (FontLoader(
|
||||
'Roboto',
|
||||
)..addFont(rootBundle.load('assets/fonts/42dotSans[wght].ttf'))).load();
|
||||
}
|
||||
|
||||
Future<void> _precacheImages(WidgetTester t) => t.runAsync(() async {
|
||||
for (final e in find.byType(Image).evaluate().toList()) {
|
||||
await precacheImage((e.widget as Image).image, e);
|
||||
}
|
||||
});
|
||||
|
||||
void main() {
|
||||
final out = Platform.environment['DESIGN_MATCH_OUT'];
|
||||
if (out == null) return;
|
||||
setUpAll(() async {
|
||||
Directory(out).createSync(recursive: true);
|
||||
await _loadFonts();
|
||||
// 이클립스 방어의 흔들기 센서. 플러그인이 없는 테스트 환경에서는 조용히 비운다.
|
||||
final messenger =
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
||||
messenger.setMockMethodCallHandler(
|
||||
const MethodChannel('dev.fluttercommunity.plus/sensors/method'),
|
||||
(_) async => null,
|
||||
);
|
||||
messenger.setMockStreamHandler(
|
||||
const EventChannel('dev.fluttercommunity.plus/sensors/accelerometer'),
|
||||
MockStreamHandler.inline(onListen: (_, _) {}),
|
||||
);
|
||||
});
|
||||
|
||||
for (final s in shots) {
|
||||
testWidgets(s.id, (t) async {
|
||||
t.view.devicePixelRatio = 2;
|
||||
t.view.physicalSize = Size(_width * 2, s.h * 2);
|
||||
t.view.padding = _padding;
|
||||
t.view.viewPadding = _padding;
|
||||
addTearDown(t.view.reset);
|
||||
|
||||
final repos = Repositories(live: false);
|
||||
final boundary = GlobalKey();
|
||||
final navigator = GlobalKey<NavigatorState>();
|
||||
Widget? pushed;
|
||||
Widget app;
|
||||
if (s.tab != null) {
|
||||
final router = buildRouter(
|
||||
AuthRepository()..signInWithMockSession(),
|
||||
repositories: repos,
|
||||
);
|
||||
router.go(s.tab!);
|
||||
app = MaterialApp.router(
|
||||
theme: buildAppTheme(),
|
||||
debugShowCheckedModeBanner: false,
|
||||
routerConfig: router,
|
||||
);
|
||||
} else {
|
||||
final screen = (await t.runAsync(() => s.build!(repos)))!;
|
||||
// 빈 화면 위에 push 해야 AppBar 가 뒤로가기 버튼을 그린다(앱에서도 push 로 연다).
|
||||
app = MaterialApp(
|
||||
theme: buildAppTheme(),
|
||||
debugShowCheckedModeBanner: false,
|
||||
navigatorKey: navigator,
|
||||
home: const SizedBox(),
|
||||
);
|
||||
pushed = screen;
|
||||
}
|
||||
await t.pumpWidget(RepaintBoundary(key: boundary, child: app));
|
||||
if (pushed != null) {
|
||||
final screen = pushed;
|
||||
navigator.currentState!.push(
|
||||
MaterialPageRoute<void>(builder: (_) => screen),
|
||||
);
|
||||
}
|
||||
await _settle(t);
|
||||
await s.after?.call(t);
|
||||
await _precacheImages(t);
|
||||
await _settle(t);
|
||||
|
||||
await t.runAsync(() async {
|
||||
final image =
|
||||
await (boundary.currentContext!.findRenderObject()!
|
||||
as RenderRepaintBoundary)
|
||||
.toImage(pixelRatio: 2);
|
||||
final png = await image.toByteData(format: ui.ImageByteFormat.png);
|
||||
await File('$out/${s.id}.png').writeAsBytes(png!.buffer.asUint8List());
|
||||
image.dispose();
|
||||
});
|
||||
// 글자 대조용 — compare.py 가 Figma spec 의 TEXT 노드와 맞춰 본다.
|
||||
final texts = [
|
||||
for (final e in find.byType(RichText).evaluate())
|
||||
if (e.renderObject case final RenderParagraph p
|
||||
when p.attached && p.text.toPlainText().trim().isNotEmpty)
|
||||
{
|
||||
'text': p.text.toPlainText(),
|
||||
'family': p.text.style?.fontFamily,
|
||||
'size': p.text.style?.fontSize,
|
||||
'weight': p.text.style?.fontWeight?.value,
|
||||
'color': p.text.style?.color?.toARGB32().toRadixString(16),
|
||||
'rect': [
|
||||
for (final v in [
|
||||
p.localToGlobal(Offset.zero).dx,
|
||||
p.localToGlobal(Offset.zero).dy,
|
||||
p.size.width,
|
||||
p.size.height,
|
||||
])
|
||||
v.roundToDouble(),
|
||||
],
|
||||
},
|
||||
];
|
||||
File(
|
||||
'$out/${s.id}.texts.json',
|
||||
).writeAsStringSync(const JsonEncoder.withIndent(' ').convert(texts));
|
||||
// 렌더 예외(오버플로 등)는 실패로 올린다 — 비교 전에 고쳐야 할 것이다.
|
||||
expect(t.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user