@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/eclipse/eclipse_data.dart'; import 'package:fancake/features/eclipse/eclipse_defend_screen.dart'; import 'package:fancake/features/home/home_onboarding_screen.dart'; import 'package:fancake/features/home/nebula_create_screen.dart'; import 'package:fancake/features/home/planet_create_screen.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import '../eclipse_stubs.dart'; import 'home_fixtures.dart'; /// 디자인 일치 그룹 A(F1 홈 · F7 이클립스) 대조 렌더. /// /// 공유 하네스(`design_match_test.dart`)와 같은 방식으로 찍되, 표본을 Figma 사본의 /// 기본 상태에 맞춘다 — 홈은 라이브 띠·이클립스 경보가 없는 「ZAP 닫힘」 상태다. /// 실행: `DESIGN_MATCH_OUT=<폴더> fvm flutter test --tags design test/design_match/a_test.dart` const _width = 375.0; const _padding = FakeViewPadding(top: 48 * 2, bottom: 34 * 2); typedef Build = Future Function(Repositories r); class Shot { const Shot( this.id, { this.build, this.tab, this.h = 812, this.after, this.repos = QuietRepos.new, }); final String id; final QuietRepos Function() repos; final Build? build; final String? tab; final double h; final Future Function(WidgetTester t)? after; } /// 이클립스 화면 — 사본 문구의 「60초」가 나오도록 마감을 60초 뒤로 둔다. Widget _defend(EclipseState state) => EclipseDefendScreen( repository: FakeEclipseRepository(state: state), fandomId: 'bts', ); DateTime get _in60 => DateTime.now().add(const Duration(seconds: 60)); final shots = [ // 사본 주 프레임(`252:159354`)은 헤더 지갑의 「아이템 구입」 시트가 열린 상태다. Shot( 'F1-01', tab: '/home', after: (t) async { await t.tap(find.byTooltip('아이템')); await _settle(t); }, ), // 기본 상태(`343:205364`) · ZAP 옵션 「아이템 선택」(`343:204868`) — 그룹 대조 폴더용. Shot('F1-01d', tab: '/home'), // 아래 구획(TODAY'S QUEST 등) 눈 대조용 긴 렌더. Shot('F1-01L', tab: '/home', h: 1400), Shot( 'F1-01i', tab: '/home', after: (t) async { final hold = await t.startGesture(t.getCenter(find.text('기여'))); await t.pump(const Duration(seconds: 2)); await hold.up(); await _settle(t); await t.tap(find.text('아이템')); await _settle(t); }, ), Shot('F1-02', build: (r) async => const NebulaCreateScreen()), // 성장 완료는 HOME 탭 성운 상태를 그대로 찍는다(ZAP 은 HOME 의 갤럭시 연결로 뜬다). Shot('F1-03', tab: '/home', repos: NebulaRepos.new), Shot('F1-04', build: (r) async => const PlanetCreateScreen()), // 이름 입력 뒤(`340:75644`) · 색 단계(`373:358639` 의 칩 줄) — 그룹 대조 폴더에서만 쓴다. Shot( 'F1-02n', build: (r) async => const NebulaCreateScreen(), after: (t) async { await t.enterText(find.byType(TextField), 'BTS_RM'); await _settle(t); }, ), Shot( 'F1-04c', build: (r) async => const PlanetCreateScreen(), after: (t) async { await t.tap(find.text('다음')); await _settle(t); }, ), // 사본 주 프레임은 「+」 시트가 열린 상태다. Shot( 'F1-05', build: (r) async => const HomeOnboardingScreen(), after: (t) async { await t.tap(find.bySemanticsLabel('행성 생성 또는 가입')); await _settle(t); }, ), Shot( 'F7-01', h: 815, build: (r) async => _defend(eclipseFixture(endsAt: _in60)), ), // 흔드는 중 — 사본 문구의 「10초」 자리는 남은 시간이다. Shot( 'F7-02', h: 815, build: (r) async => _defend( eclipseFixture(endsAt: DateTime.now().add(const Duration(seconds: 10))), ), after: (t) async { await t.tap(find.text('흔들기 어려우면 눌러서 참여')); await _settle(t); }, ), Shot( 'F7-03', h: 815, build: (r) async => _defend(eclipseFixture(defended: true)), ), // 실패 변형(`494:14292`) — 공유 비교에는 없는 ID 라 그룹 대조 폴더에서만 쓴다. Shot( 'F7-02f', h: 815, build: (r) async => _defend(eclipseFixture(result: EclipseResult.failure)), ), ]; Future _settle(WidgetTester t) async { for (var i = 0; i < 6; i++) { await t.pump(const Duration(milliseconds: 300)); } } 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(); } await (FontLoader( 'Roboto', )..addFont(rootBundle.load('assets/fonts/42dotSans[wght].ttf'))).load(); } 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 = s.repos(); 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 { pushed = (await t.runAsync(() => s.build!(repos)))!; app = MaterialApp( theme: buildAppTheme(), debugShowCheckedModeBanner: false, navigatorKey: navigator, home: const SizedBox(), ); } await t.pumpWidget(RepaintBoundary(key: boundary, child: app)); if (pushed case final screen?) { navigator.currentState!.push( MaterialPageRoute(builder: (_) => screen), ); } await _settle(t); await s.after?.call(t); await t.runAsync(() async { for (final e in find.byType(Image).evaluate().toList()) { await precacheImage((e.widget as Image).image, e); } }); 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(); }); 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); }); } }