276 lines
9.0 KiB
Dart
276 lines
9.0 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),
|
||
// Figma 「홈」(`2097:16428`) 375×2904 전체 길이 — 사본 렌더와 나란히 대조.
|
||
Shot('F1-01F', tab: '/home', h: 2904),
|
||
Shot(
|
||
'F1-01i',
|
||
tab: '/home',
|
||
after: (t) async {
|
||
// 떠 있는 ZAP 은 번개만 있다(라벨 없음) — 의미 라벨로 잡는다.
|
||
final hold = await t.startGesture(
|
||
t.getCenter(find.bySemanticsLabel(RegExp(r'^ZAP'))),
|
||
);
|
||
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);
|
||
});
|
||
}
|
||
}
|