@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 Function(Repositories r); typedef After = Future 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 _tap(WidgetTester t, String text) async { await t.tap(find.text(text).first, warnIfMissed: false); await _settle(t); } /// 표본 저장소는 Future.value · 짧은 delay 만 쓴다. 무한 애니메이션이 있어 pumpAndSettle 은 쓰지 않는다. Future _settle(WidgetTester t) async { for (var i = 0; i < 6; i++) { await t.pump(const Duration(milliseconds: 300)); } } Future _overlay( WidgetTester t, void Function(BuildContext c) open, ) async { open(t.element(find.byType(Scaffold).first)); await _settle(t); } /// 시안 알림(229:11265) 표본. 날짜 머리글이 「오늘 · 어제 · 과거」로 갈리도록 지금 기준으로 잡는다. List _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 _question(Repositories r, String id) async => (await r.inside.load()).questions.firstWhere((q) => q.id == id); final shots = [ 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 _loadFonts() async { final manifest = json.decode(await rootBundle.loadString('FontManifest.json')) as List; for (final font in manifest.cast>()) { final loader = FontLoader(font['family'] as String); for (final f in (font['fonts'] as List).cast>()) { 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 _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(); 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(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); }); } }