Files
fanuzu/app/test/design_match/a_test.dart
T

271 lines
8.7 KiB
Dart

@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<Widget> 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<void> 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 = <Shot>[
// 사본 주 프레임(`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<void> _settle(WidgetTester t) async {
for (var i = 0; i < 6; i++) {
await t.pump(const Duration(milliseconds: 300));
}
}
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();
}
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<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 {
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<void>(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);
});
}
}