앱 단위·화면·통합 테스트
This commit is contained in:
@@ -0,0 +1,270 @@
|
||||
@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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
@Tags(['design'])
|
||||
library;
|
||||
|
||||
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/galaxy/galaxy_search_screen.dart';
|
||||
import 'package:fancake/features/planet/fpi_ranking_screen.dart';
|
||||
import 'package:fancake/features/planet/lounge_post_screens.dart';
|
||||
import 'package:fancake/features/planet/moderation.dart';
|
||||
import 'package:fancake/features/planet/planet_data.dart';
|
||||
import 'package:fancake/features/planet/planet_detail_screen.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../eclipse_stubs.dart';
|
||||
import 'design_match_test.dart' as base;
|
||||
|
||||
/// 디자인 일치 2단계 **그룹 B**(F2 갤럭시 · F3 행성/성운 상세) 렌더.
|
||||
///
|
||||
/// 공유 하네스([base.main])를 그대로 돌리되 shots 만 이 그룹 것으로 바꾼다.
|
||||
/// 표본은 Figma 사본 문구와 같게 여기서 만든다(공유 표본 저장소는 건드리지 않는다).
|
||||
///
|
||||
/// 실행: `DESIGN_MATCH_OUT=<폴더> fvm flutter test --tags design test/design_match/b_test.dart`
|
||||
void main() {
|
||||
base.shots
|
||||
..clear()
|
||||
..addAll(_shots);
|
||||
base.main();
|
||||
}
|
||||
|
||||
Future<void> _settle(WidgetTester t) async {
|
||||
for (var i = 0; i < 6; i++) {
|
||||
await t.pump(const Duration(milliseconds: 300));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _tap(WidgetTester t, Finder f) async {
|
||||
await t.tap(f.first, warnIfMissed: false);
|
||||
await _settle(t);
|
||||
}
|
||||
|
||||
Future<void> _overlay(
|
||||
WidgetTester t,
|
||||
void Function(BuildContext c) open,
|
||||
) async {
|
||||
open(t.element(find.byType(Scaffold).last));
|
||||
await _settle(t);
|
||||
}
|
||||
|
||||
final _now = DateTime.now();
|
||||
|
||||
const _photo = 'assets/figma/lounge-post-sample.png';
|
||||
|
||||
LoungePost _post(
|
||||
String author,
|
||||
Duration ago,
|
||||
String text, {
|
||||
int likes = 8,
|
||||
int comments = 24,
|
||||
List<String> images = const [],
|
||||
String? photo,
|
||||
Duration? video,
|
||||
}) => LoungePost(
|
||||
id: author,
|
||||
authorName: author,
|
||||
createdAt: _now.subtract(ago),
|
||||
title: '',
|
||||
body: text,
|
||||
likes: likes,
|
||||
comments: comments,
|
||||
images: images,
|
||||
authorPhoto: photo,
|
||||
videoDuration: video,
|
||||
);
|
||||
|
||||
/// F3-05 `244:22509` — 첫 댓글 전 글.
|
||||
final _hongPost = _post(
|
||||
'BTS_Hong',
|
||||
Duration.zero,
|
||||
'안녕하세요! BTS 팬이에요!',
|
||||
likes: 0,
|
||||
comments: 0,
|
||||
images: const [_photo],
|
||||
photo: 'assets/figma/avatar-hong.png',
|
||||
video: const Duration(seconds: 36),
|
||||
);
|
||||
|
||||
/// F3-07 `252:163306` — 사진 세 장 글.
|
||||
final _ilaPost = _post(
|
||||
'ILA_WITH',
|
||||
const Duration(days: 3),
|
||||
'여러분 지금 미국 컴백 무대 보셨나요?\n사진 공유해드릴게요 모두 온라인 직관하세요',
|
||||
likes: 8,
|
||||
comments: 5,
|
||||
images: const [_photo, _photo, _photo],
|
||||
);
|
||||
|
||||
FpiScore _score(FpiMetric m, int v, int rank, int trend) =>
|
||||
FpiScore(metric: m, value: v, rank: rank, trend: trend, myTopPercent: 1);
|
||||
|
||||
/// 시안 수치 그대로의 행성 · 성운 상세.
|
||||
class _FigmaPlanet extends SamplePlanetRepository {
|
||||
const _FigmaPlanet();
|
||||
|
||||
@override
|
||||
Future<PlanetDetail> load({required bool nebula}) async => nebula
|
||||
? PlanetDetail(
|
||||
fandomId: 'bts-rm',
|
||||
name: 'BTS_RM',
|
||||
isNebula: true,
|
||||
stage: 0,
|
||||
totalFpi: 0,
|
||||
members: 1,
|
||||
ownerName: 'BTS_Hong',
|
||||
ownerPhoto: 'assets/figma/avatar-hong.png',
|
||||
scores: const [],
|
||||
posts: const [],
|
||||
)
|
||||
: PlanetDetail(
|
||||
fandomId: 'bts',
|
||||
name: 'BTS',
|
||||
isNebula: false,
|
||||
totalFpi: 1220,
|
||||
members: 850,
|
||||
ownerName: 'BTS_Maker',
|
||||
ownerPhoto: 'assets/figma/avatar-maker.png',
|
||||
scores: [
|
||||
_score(FpiMetric.bpm, 34200, 2, 1),
|
||||
_score(FpiMetric.health, 1180000, 4, -1),
|
||||
_score(FpiMetric.referral, 34200, 3, 0),
|
||||
_score(FpiMetric.contribution, 450100, 2, 2),
|
||||
_score(FpiMetric.community, 1000, 4, -2),
|
||||
],
|
||||
posts: [
|
||||
_post(
|
||||
'arrrmy_',
|
||||
const Duration(minutes: 3),
|
||||
'I love BTS sooooo much 😭😭😭',
|
||||
),
|
||||
_post(
|
||||
'AR_NNJO',
|
||||
const Duration(hours: 3),
|
||||
'NAMJOON so handsome isn’t it???',
|
||||
images: const [_photo],
|
||||
),
|
||||
_ilaPost,
|
||||
],
|
||||
);
|
||||
|
||||
@override
|
||||
Future<List<LoungePost>> morePosts({
|
||||
required int offset,
|
||||
int limit = 5,
|
||||
}) async => const [];
|
||||
|
||||
@override
|
||||
Future<FpiRanking> planetRanking({
|
||||
required FpiMetric metric,
|
||||
int limit = 30,
|
||||
}) async => FpiRanking(
|
||||
mine: const FpiRankRow(
|
||||
rank: 24,
|
||||
id: 'bts',
|
||||
name: 'BTS',
|
||||
value: 34200,
|
||||
change: 12,
|
||||
isMine: true,
|
||||
),
|
||||
rows: _rows(const [
|
||||
(1, 'Bigbnag', 782200, 1),
|
||||
(2, 'NewJeans', 703200, 0),
|
||||
(3, 'Blackpink', 689700, 1),
|
||||
(5, 'Twice', 531100, -1),
|
||||
(6, 'ITZY', 527800, 2),
|
||||
(7, 'Aespa', 422400, 3),
|
||||
(8, 'Seventeen', 329600, 8),
|
||||
(9, 'Eighteen', 311200, -1),
|
||||
(10, 'Nineteen', 134500, 1),
|
||||
]),
|
||||
);
|
||||
|
||||
@override
|
||||
Future<FpiRanking> userRanking({
|
||||
required FpiMetric metric,
|
||||
required String fandomId,
|
||||
int limit = 30,
|
||||
}) async => FpiRanking(
|
||||
mine: const FpiRankRow(
|
||||
rank: 82,
|
||||
id: 'me',
|
||||
name: 'BTS_Hong',
|
||||
value: 3,
|
||||
change: 1,
|
||||
),
|
||||
rows: _rows(const [
|
||||
(1, 'GLAS_SMILE', 3000, 0),
|
||||
(2, 'Suyom_mo', 2600, 0),
|
||||
(3, 'Bool_huuu', 1400, 0),
|
||||
(4, 'Wachooo', 1200, 1),
|
||||
(5, 'Jeans_Bewn', 1200, 0),
|
||||
(6, 'pinkyy', 1100, 1),
|
||||
(7, 'T_T', 1000, -1),
|
||||
(8, 'Yogi_ITZY', 98, 2),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
List<FpiRankRow> _rows(List<(int, String, int, int)> t) => [
|
||||
for (final (rank, name, value, change) in t)
|
||||
FpiRankRow(rank: rank, id: name, name: name, value: value, change: change),
|
||||
];
|
||||
|
||||
Widget _detail({bool nebula = false}) => PlanetDetailScreen(
|
||||
nebula: nebula,
|
||||
repository: const _FigmaPlanet(),
|
||||
eclipse: const NoEclipseRepository(),
|
||||
);
|
||||
|
||||
const _report = ModerationTarget(
|
||||
authorName: 'BTS_Hong',
|
||||
title: '공방 신청 꿀팁 공유합니다.',
|
||||
);
|
||||
|
||||
/// 이클립스가 없는 표본 저장소. 탭 화면은 공유 하네스가 저장소를 직접 만들어서
|
||||
/// 바꿀 수 없으므로, 탭 셸을 통째로 한 번 더 띄운다(하단 탭까지 그려진다).
|
||||
class _NoEclipseRepos extends Repositories {
|
||||
_NoEclipseRepos() : super(live: false);
|
||||
|
||||
@override
|
||||
EclipseRepository get eclipse => const NoEclipseRepository();
|
||||
}
|
||||
|
||||
Widget _tab(String path) {
|
||||
final router = buildRouter(
|
||||
AuthRepository()..signInWithMockSession(),
|
||||
repositories: _NoEclipseRepos(),
|
||||
)..go(path);
|
||||
return MaterialApp.router(
|
||||
theme: buildAppTheme(),
|
||||
debugShowCheckedModeBanner: false,
|
||||
routerConfig: router,
|
||||
);
|
||||
}
|
||||
|
||||
final _shots = <base.Shot>[
|
||||
// F2 갤럭시
|
||||
base.Shot('F2-01', build: (_) async => _tab('/galaxy')),
|
||||
base.Shot(
|
||||
'F2-02',
|
||||
build: (r) async => GalaxySearchScreen(repository: r.galaxy),
|
||||
),
|
||||
// F3 행성/성운 상세
|
||||
base.Shot('F3-01', h: 1042, build: (_) async => _detail(nebula: true)),
|
||||
base.Shot(
|
||||
'F3-02',
|
||||
build: (_) async => const ReportScreen(
|
||||
target: ModerationTarget(authorName: 'BTS_Hong', title: 'BTS_RM'),
|
||||
),
|
||||
),
|
||||
base.Shot(
|
||||
'F3-03',
|
||||
build: (_) async => _detail(nebula: true),
|
||||
after: (t) => _overlay(
|
||||
t,
|
||||
(c) => confirmBlock(c, 'BTS_Hong', message: '이 성운을 차단할까요?'),
|
||||
),
|
||||
),
|
||||
base.Shot('F3-04', h: 1243, build: (_) async => _detail()),
|
||||
base.Shot('F3-05', build: (_) async => LoungePostScreen(post: _hongPost)),
|
||||
base.Shot('F3-06', build: (_) async => const ReportScreen(target: _report)),
|
||||
base.Shot(
|
||||
'F3-07',
|
||||
build: (_) async => LoungePostScreen(post: _ilaPost),
|
||||
after: (t) => _overlay(t, (c) => confirmBlock(c, 'ILA_WITH')),
|
||||
),
|
||||
base.Shot('F3-09', build: (_) async => const LoungeComposeScreen()),
|
||||
base.Shot(
|
||||
'F3-10',
|
||||
build: (_) async => const FpiRankingScreen(
|
||||
metric: FpiMetric.bpm,
|
||||
repository: _FigmaPlanet(),
|
||||
),
|
||||
),
|
||||
base.Shot(
|
||||
'F3-11',
|
||||
build: (_) async => const FpiUserRankingScreen(
|
||||
metric: FpiMetric.bpm,
|
||||
fandomId: 'bts',
|
||||
planetName: 'BTS',
|
||||
repository: _FigmaPlanet(),
|
||||
),
|
||||
),
|
||||
base.Shot(
|
||||
'F3-12',
|
||||
build: (_) async => _detail(),
|
||||
after: (t) async {
|
||||
await _tap(t, find.text('팬덤 라운지'));
|
||||
await _tap(
|
||||
t,
|
||||
find.image(const AssetImage('assets/figma/icon-post-more.png')),
|
||||
);
|
||||
},
|
||||
),
|
||||
base.Shot('F3-13', build: (_) async => const ReportScreen(target: _report)),
|
||||
];
|
||||
@@ -0,0 +1,499 @@
|
||||
@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/campaign/campaign_create_screen.dart';
|
||||
import 'package:fancake/features/campaign/campaign_data.dart';
|
||||
import 'package:fancake/features/campaign/campaign_detail_screen.dart';
|
||||
import 'package:fancake/features/campaign/campaign_list_screen.dart';
|
||||
import 'package:fancake/features/campaign/campaign_ranking_screen.dart';
|
||||
import 'package:fancake/features/campaign/campaign_search_screen.dart';
|
||||
import 'package:fancake/features/campaign/widgets/campaign_period_sheet.dart';
|
||||
import 'package:fancake/features/planet/moderation.dart';
|
||||
import 'package:fancake/features/quest/place_picker_screen.dart';
|
||||
import 'package:fancake/features/quest/quest_ad_screens.dart';
|
||||
import 'package:fancake/features/quest/quest_create_screen.dart';
|
||||
import 'package:fancake/features/quest/quest_data.dart';
|
||||
import 'package:fancake/features/quest/quest_detail_screen.dart';
|
||||
import 'package:fancake/features/quest/quest_list_screen.dart';
|
||||
import 'package:fancake/features/quest/quest_reward_screen.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
/// 디자인 일치 그룹 C(F4 퀘스트 · F5 캠페인) 전용 하네스. 본체는 design_match_test.dart 와 같다.
|
||||
///
|
||||
/// Figma 「계약 시점 사본」 화면과 나란히 놓을 앱 렌더를 뽑는다.
|
||||
///
|
||||
/// 실행: `DESIGN_MATCH_OUT=<폴더> fvm flutter test --tags design test/design_match` (단일: `--plain-name F6-01`)
|
||||
/// 출력: `$DESIGN_MATCH_OUT/<id>.png`. 변수가 없으면 일반 `flutter test` 에서 건너뛴다. 비교는 tools/design/compare.py.
|
||||
///
|
||||
/// 화면 ID·Figma 노드는 디자인 대조 자료(화면 목록 · 매핑 근거).
|
||||
/// 여기 없는 ID 는 앱 화면이 없거나(없음) 계약 제외(이메일·X·WeChat 가입 등)다.
|
||||
|
||||
/// 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> _period(WidgetTester t, List<String> days) async {
|
||||
showCampaignPeriodSheet(
|
||||
t.element(find.byType(Scaffold).first),
|
||||
today: DateTime(2026, 4, 1),
|
||||
);
|
||||
await _settle(t);
|
||||
for (final d in days) {
|
||||
await t.tap(
|
||||
find.descendant(
|
||||
of: find.byType(CampaignPeriodSheet),
|
||||
matching: find.text(d),
|
||||
).first,
|
||||
warnIfMissed: false,
|
||||
);
|
||||
await _settle(t);
|
||||
}
|
||||
}
|
||||
|
||||
final shots = <Shot>[
|
||||
// F4 퀘스트 — 표본은 Figma 문구·수치와 같은 _F4Quests.
|
||||
Shot(
|
||||
'F4-01',
|
||||
build: (r) async => QuestListScreen(repository: _F4Quests.mine(), myPop: 12540),
|
||||
after: (t) => _tap(t, '내 퀘스트'),
|
||||
),
|
||||
Shot(
|
||||
'F4-02',
|
||||
build: (r) async =>
|
||||
QuestDetailScreen(questId: 'm1', repository: _F4Quests.detail()),
|
||||
),
|
||||
Shot(
|
||||
'F4-03',
|
||||
build: (r) async => const ReportScreen(
|
||||
target: ModerationTarget(authorName: 'BTS_Hong', title: '만보기'),
|
||||
),
|
||||
),
|
||||
Shot('F4-04', build: (r) async => QuestCreateScreen(repository: _F4Quests.all())),
|
||||
Shot('F4-05', build: (r) async => const PlacePickerScreen()),
|
||||
Shot(
|
||||
'F4-06',
|
||||
build: (r) async => QuestListScreen(repository: _F4Quests.all(), myPop: 12540),
|
||||
),
|
||||
// 추가 보상 모달 — 관리자 퀘스트에서 「보상받기」를 누른 상태.
|
||||
Shot(
|
||||
'F4-07',
|
||||
build: (r) async =>
|
||||
QuestDetailScreen(questId: 'a0', repository: _F4Quests.attend()),
|
||||
after: (t) => _tap(t, '보상받기'),
|
||||
),
|
||||
Shot('F4-08', build: (r) async => const QuestAdWarpScreen.before()),
|
||||
Shot('F4-09', build: (r) async => const QuestAdWarpScreen.after()),
|
||||
Shot(
|
||||
'F4-10',
|
||||
build: (r) async => QuestRewardScreen(
|
||||
detail: await _F4Quests.all().detail('u1'),
|
||||
reward: const QuestReward(rewardedPop: 10, balanceAfter: 12550),
|
||||
),
|
||||
),
|
||||
// F5 캠페인 — 표본은 Figma 문구·수치와 같은 _F5Campaigns.
|
||||
Shot('F5-01', build: (r) async => CampaignListScreen(repository: _F5Campaigns())),
|
||||
// 시안은 검색어 「BTS」를 입력한 채 최근 검색어를 보여 준다(제출 전).
|
||||
Shot(
|
||||
'F5-02',
|
||||
build: (r) async => CampaignSearchScreen(repository: _F5Campaigns()),
|
||||
after: (t) async {
|
||||
await t.enterText(find.byType(TextField), 'BTS');
|
||||
await _settle(t);
|
||||
},
|
||||
),
|
||||
Shot(
|
||||
'F5-03',
|
||||
build: (r) async => CampaignSearchScreen(repository: _F5Campaigns()),
|
||||
after: (t) async {
|
||||
await t.enterText(find.byType(TextField), 'BTS');
|
||||
await t.testTextInput.receiveAction(TextInputAction.search);
|
||||
await _settle(t);
|
||||
},
|
||||
),
|
||||
Shot(
|
||||
'F5-04',
|
||||
build: (r) async =>
|
||||
CampaignDetailScreen(campaignId: 'c1', repository: _F5Campaigns()),
|
||||
after: (t) async {
|
||||
await t.tap(find.byTooltip('공유'));
|
||||
await _settle(t);
|
||||
},
|
||||
),
|
||||
Shot(
|
||||
'F5-05',
|
||||
h: 975,
|
||||
build: (r) async => CampaignCreateScreen(repository: _F5Campaigns()),
|
||||
),
|
||||
// 캘린더 시트 두 상태 — 사본 `333:72497`(시작일 9 만) · `333:72719`(9 ~ 23).
|
||||
// 사본 달력이 2026년 4월이라 「오늘」을 4월 1일로 고정한다(1~8 도 고를 수 있는 날).
|
||||
Shot(
|
||||
'F5-05__333-72497',
|
||||
build: (r) async => CampaignCreateScreen(repository: _F5Campaigns()),
|
||||
after: (t) => _period(t, ['9']),
|
||||
),
|
||||
Shot(
|
||||
'F5-05__333-72719',
|
||||
build: (r) async => CampaignCreateScreen(repository: _F5Campaigns()),
|
||||
after: (t) => _period(t, ['9', '23']),
|
||||
),
|
||||
// Figma 기본 프레임은 「기여하기」 시트가 열린 상태다.
|
||||
Shot(
|
||||
'F5-06',
|
||||
build: (r) async =>
|
||||
CampaignDetailScreen(campaignId: 'c1', repository: _F5Campaigns()),
|
||||
after: (t) => _tap(t, '기여하기'),
|
||||
),
|
||||
Shot(
|
||||
'F5-07',
|
||||
build: (r) async =>
|
||||
CampaignRankingScreen(campaignId: 'c1', repository: _F5Campaigns()),
|
||||
),
|
||||
Shot(
|
||||
'F5-08',
|
||||
build: (r) async => const ReportScreen(
|
||||
target: ModerationTarget(authorName: 'BTS_Hong', title: '강남역 초대형 LED 광고'),
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
/// F5 표본 — Figma 사본(272:241184 · 272:245738 · 333:70971 · 272:241681)의 문구·수치.
|
||||
/// F4 표본 — 시안 `264:46867`(전체) · `259:46109`(내 퀘스트) · `264:48835`(상세) 문구·수치.
|
||||
class _F4Quests extends SampleQuestRepository {
|
||||
const _F4Quests(this.cards);
|
||||
|
||||
_F4Quests.all()
|
||||
: cards = const [
|
||||
QuestCard(
|
||||
id: 'a1', title: '출석체크', categoryName: '오늘의 출석체크',
|
||||
type: QuestType.attend, author: QuestAuthor.admin,
|
||||
stage: QuestStage.notStarted, goal: 1, progress: 0, rewardPop: 10,
|
||||
participants: 0,
|
||||
),
|
||||
QuestCard(
|
||||
id: 'a2', title: '좋아요', categoryName: '커뮤니티',
|
||||
author: QuestAuthor.admin, stage: QuestStage.notStarted,
|
||||
goal: 5, progress: 0, rewardPop: 10, participants: 0,
|
||||
),
|
||||
QuestCard(
|
||||
id: 'a3', title: '댓글', categoryName: '커뮤니티',
|
||||
author: QuestAuthor.admin, stage: QuestStage.notStarted,
|
||||
goal: 5, progress: 0, rewardPop: 10, participants: 0,
|
||||
),
|
||||
QuestCard(
|
||||
id: 'u1', title: '댓글', categoryName: '커뮤니티',
|
||||
author: QuestAuthor.user, stage: QuestStage.completed,
|
||||
goal: 5, progress: 5, rewardPop: 10, participants: 0, capacity: 5,
|
||||
),
|
||||
QuestCard(
|
||||
id: 'u2', title: '좋아요', categoryName: '커뮤니티',
|
||||
author: QuestAuthor.user, stage: QuestStage.inProgress,
|
||||
goal: 10, progress: 0, rewardPop: 15, participants: 0, capacity: 5,
|
||||
),
|
||||
QuestCard(
|
||||
id: 'u3', title: '만보기', categoryName: '건강',
|
||||
type: QuestType.steps, author: QuestAuthor.user,
|
||||
stage: QuestStage.notStarted, goal: 10000, progress: 0,
|
||||
rewardPop: 150, participants: 0, capacity: 5,
|
||||
),
|
||||
];
|
||||
|
||||
_F4Quests.mine()
|
||||
: cards = const [
|
||||
QuestCard(
|
||||
id: 'm1', title: '만보기', categoryName: '건강',
|
||||
type: QuestType.steps, author: QuestAuthor.user,
|
||||
stage: QuestStage.mine, goal: 10000, progress: 0, rewardPop: 150,
|
||||
participants: 0, capacity: 5,
|
||||
),
|
||||
];
|
||||
|
||||
_F4Quests.attend()
|
||||
: cards = const [
|
||||
QuestCard(
|
||||
id: 'a0', title: '출석 체크 보상을 받아보세요',
|
||||
categoryName: '오늘의 출석체크', type: QuestType.attend,
|
||||
author: QuestAuthor.admin, stage: QuestStage.completed,
|
||||
goal: 1, progress: 1, rewardPop: 50, participants: 0,
|
||||
),
|
||||
];
|
||||
|
||||
/// 상세 시안은 목록과 제목이 다르다.
|
||||
_F4Quests.detail()
|
||||
: cards = const [
|
||||
QuestCard(
|
||||
id: 'm1', title: '뉴진스 미니앨범 축하 퀘스트', categoryName: '건강',
|
||||
type: QuestType.steps, author: QuestAuthor.user,
|
||||
stage: QuestStage.mine, goal: 10000, progress: 0, rewardPop: 150,
|
||||
participants: 0, capacity: 5,
|
||||
),
|
||||
];
|
||||
|
||||
final List<QuestCard> cards;
|
||||
|
||||
@override
|
||||
Future<List<QuestCard>> load({int limit = 10}) async => cards;
|
||||
|
||||
@override
|
||||
Future<List<QuestCard>> more({required int offset, int limit = 10}) async =>
|
||||
const [];
|
||||
|
||||
/// 시안의 카테고리 네 개(C-01 분류 키).
|
||||
@override
|
||||
Future<QuestCatalog> catalog() async => const QuestCatalog(categories: [
|
||||
QuestCategory(id: 'health', name: '건강', activities: [QuestActivity(key: 'walk', name: '걷기')]),
|
||||
QuestCategory(id: 'planet', name: '행성 활동', activities: [QuestActivity(key: 'vote', name: '투표 참여')]),
|
||||
QuestCategory(id: 'community', name: '커뮤니티', activities: [QuestActivity(key: 'community', name: '커뮤니티')]),
|
||||
QuestCategory(id: 'location', name: '특정 위치', activities: [QuestActivity(key: 'location', name: '특정 위치', fields: {'place'})]),
|
||||
]);
|
||||
|
||||
@override
|
||||
Future<QuestDetail> detail(String questId) async => QuestDetail(
|
||||
card: cards.firstWhere((c) => c.id == questId),
|
||||
imageLabel: '',
|
||||
description: '',
|
||||
);
|
||||
}
|
||||
|
||||
class _F5Campaigns extends SampleCampaignRepository {
|
||||
static final _now = DateTime.now();
|
||||
|
||||
static CampaignCardData _card(String id) => CampaignCardData(
|
||||
id: id,
|
||||
title: '강남역 초대형 LED 광고',
|
||||
body: 'BTS 데뷔 기념 팬 연합의 초대형 프로젝트입니다.\n도심을 BTS의 색으로 물들여주세요.',
|
||||
authorName: '다이브짱',
|
||||
createdAt: _now.subtract(const Duration(minutes: 10)),
|
||||
status: CampaignStatus.funding,
|
||||
goalPop: 1000000,
|
||||
raisedPop: 920000,
|
||||
supporters: 1834,
|
||||
likes: 48,
|
||||
comments: 24,
|
||||
views: 82,
|
||||
shares: 856,
|
||||
);
|
||||
|
||||
static const _ranks = [
|
||||
ContributorRank(rank: 1, name: 'GLAS_SMILE', pop: 3000),
|
||||
ContributorRank(rank: 2, name: 'Suyom_mo', pop: 2600),
|
||||
ContributorRank(rank: 3, name: 'Bool_huuu', pop: 1400),
|
||||
ContributorRank(rank: 4, name: 'Wachooo', pop: 1200, change: 1),
|
||||
ContributorRank(rank: 5, name: 'Jeans_Bewn', pop: 1200),
|
||||
ContributorRank(rank: 6, name: 'pinkyy', pop: 1100, change: 1),
|
||||
ContributorRank(rank: 7, name: 'T_T', pop: 1000, change: -1),
|
||||
ContributorRank(rank: 8, name: 'Yogi_ITZY', pop: 98, change: 2),
|
||||
ContributorRank(rank: 9, name: 'SaunaSupa', pop: 97, change: 3),
|
||||
ContributorRank(rank: 10, name: '17_10', pop: 88, change: 8),
|
||||
ContributorRank(rank: 11, name: 'Getting_OLD', pop: 87, change: -1),
|
||||
];
|
||||
|
||||
final _queries = ['BTS', 'BTS 광화문 공연', '아미', '방탄소년단', '지민'];
|
||||
|
||||
@override
|
||||
Future<List<CampaignCardData>> load({
|
||||
CampaignSort sort = CampaignSort.latest,
|
||||
int limit = 10,
|
||||
}) async => [_card('c1'), _card('c2')];
|
||||
|
||||
@override
|
||||
Future<List<CampaignCardData>> more({required int offset, int limit = 10}) async => const [];
|
||||
|
||||
@override
|
||||
Future<CampaignDetail> detail(String campaignId) async => CampaignDetail(
|
||||
card: _card(campaignId),
|
||||
mediaLabels: const ['대표 이미지'],
|
||||
endsAt: DateTime.now().add(
|
||||
const Duration(days: 6, hours: 23, minutes: 59, seconds: 6, milliseconds: 900),
|
||||
),
|
||||
topContributors: _ranks.take(7).toList(),
|
||||
);
|
||||
|
||||
@override
|
||||
Future<List<ContributorRank>> ranking(
|
||||
String campaignId, {
|
||||
RankWindow window = RankWindow.live,
|
||||
}) async => [
|
||||
..._ranks,
|
||||
const ContributorRank(rank: 82, name: 'BTS_Hong', pop: 3, change: 1, isMe: true),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<List<String>> recentQueries() async => [..._queries];
|
||||
|
||||
@override
|
||||
Future<List<CampaignCardData>> search(String query) async => [_card('c1'), _card('c2')];
|
||||
|
||||
@override
|
||||
Future<int> balance() async => 12500;
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
@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.dart';
|
||||
import 'package:fancake/features/auth/auth_repository.dart';
|
||||
import 'package:fancake/features/campaign/campaign_data.dart';
|
||||
import 'package:fancake/features/my/my_data.dart';
|
||||
import 'package:fancake/features/my/my_lists_screen.dart';
|
||||
import 'package:fancake/features/my/notice_screens.dart';
|
||||
import 'package:fancake/features/my/payment_screens.dart';
|
||||
import 'package:fancake/features/my/pop_history_screen.dart';
|
||||
import 'package:fancake/features/my/profile_edit_screen.dart';
|
||||
import 'package:fancake/features/my/push_settings_screen.dart';
|
||||
import 'package:fancake/features/my/settings_screen.dart';
|
||||
import 'package:fancake/features/my/support_screens.dart';
|
||||
import 'package:fancake/features/my/tier_screens.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
/// 그룹 D(F6 MY) 디자인 대조 렌더 — 표본을 Figma 사본 문구에 맞춘다.
|
||||
///
|
||||
/// 실행: `DESIGN_MATCH_OUT=<폴더> fvm flutter test --tags design test/design_match/d_test.dart`
|
||||
/// 렌더 방식은 공유 하네스(design_match_test.dart)와 같고, 저장소만 [_FigmaMy] 로 바꾼다.
|
||||
|
||||
const _width = 375.0;
|
||||
const _padding = FakeViewPadding(top: 48 * 2, bottom: 34 * 2);
|
||||
|
||||
/// Figma 사본의 표본 문구(BTS_Hong · hon******@gmail.com …). 숫자는 원장 체계(C-18)로 담는다.
|
||||
class _FigmaMy extends SampleMyRepository {
|
||||
@override
|
||||
Future<MyProfile> profile() async => const MyProfile(
|
||||
nickname: 'BTS_Hong',
|
||||
email: 'hon******@gmail.com',
|
||||
contributedPop: 350,
|
||||
userExp: 100,
|
||||
userLevel: 1,
|
||||
balancePop: 12540,
|
||||
steps: 1000,
|
||||
completedQuests: 3,
|
||||
planetTiers: [
|
||||
PlanetTier(fandomName: 'BTS', contributedPop: 100, isPrimary: true),
|
||||
PlanetTier(fandomName: 'NewJeans', contributedPop: 100),
|
||||
PlanetTier(fandomName: 'IVE', contributedPop: 100),
|
||||
PlanetTier(fandomName: 'aespa', contributedPop: 100),
|
||||
PlanetTier(fandomName: 'SEVENTEEN', contributedPop: 100),
|
||||
PlanetTier(fandomName: 'LE SSERAFIM', contributedPop: 100),
|
||||
],
|
||||
);
|
||||
|
||||
@override
|
||||
Future<List<PopLedgerEntry>> popHistory({
|
||||
int limit = 20,
|
||||
PopCursor? after,
|
||||
}) async => after != null
|
||||
? const []
|
||||
: [
|
||||
for (final (d, title, type, delta, balance) in const [
|
||||
(5, 'BTS', '행성 기여', -100, 12640),
|
||||
(4, '만보기', '퀘스트 생성', -750, 13390),
|
||||
(4, '좋아요', '퀘스트 완료', 150, 13240),
|
||||
(3, '강남역 초대형 LED 광고', '캠페인 기여', -300, 13540),
|
||||
(2, 'BTS_RM', '성운 기여', -100, 13640),
|
||||
(1, 'BTS 컴백 알려주세요!', '인사이드 생성', -100, 13640),
|
||||
(0, '친구 초대', '레퍼럴', 150, 13640),
|
||||
(-1, '여기 화면 어떻게 하나요?', '인사이드 채택', 150, 13640),
|
||||
])
|
||||
PopLedgerEntry(
|
||||
at: DateTime(2026, 5, d, 12),
|
||||
title: title,
|
||||
type: type,
|
||||
kind: PopKind.general,
|
||||
txType: delta < 0 ? PopTxType.spend : PopTxType.grant,
|
||||
delta: delta,
|
||||
balanceAfter: balance,
|
||||
cursor: PopCursor(at: DateTime(2026, 5, d, 12), id: title),
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<List<NoticeItem>> notices() async => [
|
||||
for (final (i, (title, m, d)) in const [
|
||||
('[공지] 2026년 하반기 업데이트 관련 안내', 4, 30),
|
||||
('[공지] 2026년 하반기 업데이트 관련 안내', 4, 30),
|
||||
('서비스 첫 출시 관련 수정 안내', 4, 30),
|
||||
('새로운 기능 추가 및 성능 개선', 5, 10),
|
||||
('봄맞이 할인 이벤트 안내', 5, 5),
|
||||
('서버 정기 점검 안내', 5, 15),
|
||||
('이용약관 변경 안내', 5, 20),
|
||||
('신규 사용자 가이드 배포', 5, 25),
|
||||
('휴대폰 앱 업데이트 권장', 5, 30),
|
||||
('긴급 보안 패치 적용', 6, 1),
|
||||
].indexed)
|
||||
NoticeItem(
|
||||
id: 'n$i',
|
||||
title: title,
|
||||
// 시안 본문의 「팬케이크」는 사용자 노출 브랜드 규칙으로 「팬우주」.
|
||||
body:
|
||||
'안녕하세요. 팬우주 운영진 입니다.\n2026년 하반기를 앞두고 아래의 업데이트 관련 내용을 전달드리려고 합니다!\n\n아래의 내용들은 이미지와 함께 첨부했으니 확인해주시기 바랍니다.\n\n감사합니다.',
|
||||
at: DateTime(2026, m, d),
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<List<InquiryItem>> myInquiries() async => [
|
||||
for (final (i, s) in const [
|
||||
InquiryStatus.answered,
|
||||
InquiryStatus.inProgress,
|
||||
InquiryStatus.pending,
|
||||
].indexed)
|
||||
InquiryItem(
|
||||
id: 'q$i',
|
||||
title: i == 0 ? '구매 상품 환불 관련 문의 접수' : '서비스 이용 관련 문의 접수',
|
||||
body: '문의 내용',
|
||||
at: DateTime(2026, 4, 30),
|
||||
status: s,
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<List<PaymentEntry>> payments() async => [
|
||||
for (final (i, (m, price)) in const [
|
||||
(5, 3900),
|
||||
(6, 4500),
|
||||
(7, 5200),
|
||||
(8, 6100),
|
||||
(9, 2800),
|
||||
(10, 4750),
|
||||
(11, 3600),
|
||||
(12, 5000),
|
||||
// 시안 「13.05 · 14.05 · 15.05」 는 달력에 없는 날짜다. 달을 넘겨 01~03.05 로 그린다.
|
||||
(13, 6450),
|
||||
(14, 4900),
|
||||
(15, 3750),
|
||||
].indexed)
|
||||
PaymentEntry(
|
||||
id: 'IAP2405110${i + 1}',
|
||||
at: DateTime(2026, m, 5),
|
||||
itemName: '아이템${i + 1}',
|
||||
price: price,
|
||||
method: 'iOS',
|
||||
state: PaymentState.paid,
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<PaymentEntry> payment(String id) async =>
|
||||
(await payments()).firstWhere((p) => p.id == id);
|
||||
|
||||
@override
|
||||
Future<({String current, bool latest})> appVersion() async =>
|
||||
(current: '0.4.0', latest: true);
|
||||
}
|
||||
|
||||
/// 시안 `370:229665` 의 내 캠페인 한 장.
|
||||
class _FigmaCampaign extends SampleCampaignRepository {
|
||||
@override
|
||||
Future<List<CampaignCardData>> load({
|
||||
CampaignSort sort = CampaignSort.latest,
|
||||
int limit = 10,
|
||||
}) async => [
|
||||
CampaignCardData(
|
||||
id: 'c-mine',
|
||||
title: 'BTS 인스타 광고 캠페인',
|
||||
body:
|
||||
'BTS 인스타 광고 캠페인 진행하실 아미분들!!!\n매년 찾아오는 인스타 광고에서 저희 방탄을 위해 화력을 보여주세요!!',
|
||||
authorName: 'BTS_Hong',
|
||||
createdAt: DateTime.now(),
|
||||
status: CampaignStatus.funding,
|
||||
goalPop: 30000,
|
||||
raisedPop: 0,
|
||||
supporters: 0,
|
||||
likes: 0,
|
||||
comments: 0,
|
||||
views: 0,
|
||||
shares: 0,
|
||||
mine: true,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
class _Repos extends Repositories {
|
||||
_Repos() : super(live: false);
|
||||
|
||||
@override
|
||||
late final MyRepository my = _FigmaMy();
|
||||
|
||||
@override
|
||||
late final CampaignRepository campaign = _FigmaCampaign();
|
||||
|
||||
@override
|
||||
AccountLinker linker(List<SocialAuthProvider> providers) =>
|
||||
SampleAccountLinker();
|
||||
}
|
||||
|
||||
typedef Build = Future<Widget> Function(Repositories r);
|
||||
|
||||
class Shot {
|
||||
const Shot(this.id, {this.build, this.tab, this.h = 812, this.after});
|
||||
final String id;
|
||||
final Build? build;
|
||||
final String? tab;
|
||||
final double h;
|
||||
final Future<void> Function(WidgetTester t)? after;
|
||||
}
|
||||
|
||||
Future<void> _settle(WidgetTester t) async {
|
||||
for (var i = 0; i < 6; i++) {
|
||||
await t.pump(const Duration(milliseconds: 300));
|
||||
}
|
||||
}
|
||||
|
||||
Widget _settings(Repositories r) =>
|
||||
SettingsScreen(repository: r.my, linker: r.linker(const []));
|
||||
|
||||
final shots = <Shot>[
|
||||
Shot('F6-01', tab: '/my'),
|
||||
Shot(
|
||||
'F6-02',
|
||||
build: (r) async => const MyListsScreen(kind: MyListKind.inside),
|
||||
),
|
||||
Shot(
|
||||
'F6-03',
|
||||
build: (r) async =>
|
||||
MyListsScreen(kind: MyListKind.campaign, campaigns: r.campaign),
|
||||
),
|
||||
Shot('F6-04', h: 1344, build: (r) async => _settings(r)),
|
||||
// F6-05~07 은 설정 위에 뜨는 OS 제공자 시트다. 시트 자체는 OS 가 그리므로(계약 제외)
|
||||
// 아래 깔린 설정 화면만 대조한다.
|
||||
Shot('F6-05', build: (r) async => _settings(r)),
|
||||
Shot('F6-06', build: (r) async => _settings(r)),
|
||||
Shot('F6-07', build: (r) async => _settings(r)),
|
||||
Shot('F6-08', h: 959, build: (r) async => PopHistoryScreen(repository: r.my)),
|
||||
Shot(
|
||||
'F6-10',
|
||||
h: 1276,
|
||||
build: (r) async => NoticeListScreen(repository: r.my),
|
||||
),
|
||||
Shot(
|
||||
'F6-11',
|
||||
build: (r) async =>
|
||||
NoticeDetailScreen(notice: (await r.my.notices()).first),
|
||||
),
|
||||
Shot('F6-12', build: (r) async => PaymentHistoryScreen(repository: r.my)),
|
||||
Shot(
|
||||
'F6-13',
|
||||
build: (r) async =>
|
||||
PaymentDetailScreen(paymentId: 'IAP24051101', repository: r.my),
|
||||
),
|
||||
Shot(
|
||||
'F6-14',
|
||||
build: (r) async =>
|
||||
RefundRequestScreen(paymentId: 'IAP24051101', repository: r.my),
|
||||
),
|
||||
Shot(
|
||||
'F6-15',
|
||||
build: (r) async =>
|
||||
TierDetailScreen(tiers: (await r.my.profile()).planetTiers),
|
||||
),
|
||||
Shot('F6-16', build: (r) async => const TierInfoScreen()),
|
||||
// 시안은 앨범에서 사진을 고른 뒤(완료 활성) 상태다.
|
||||
Shot(
|
||||
'F6-17',
|
||||
build: (r) async {
|
||||
final bytes = (await rootBundle.load(
|
||||
'assets/figma/avatar-sample.png',
|
||||
)).buffer.asUint8List();
|
||||
return ProfilePhotoScreen(
|
||||
pickPhoto: (_) async => PickedPhoto(
|
||||
bytes: bytes,
|
||||
contentType: 'image/png',
|
||||
name: 'avatar-sample.png',
|
||||
),
|
||||
);
|
||||
},
|
||||
after: (t) async {
|
||||
await t.tap(find.bySemanticsLabel('앨범에서 고르기'));
|
||||
await _settle(t);
|
||||
},
|
||||
),
|
||||
// 시안은 닉네임을 바꾼 뒤(완료 활성) 상태다.
|
||||
Shot(
|
||||
'F6-18',
|
||||
build: (r) async => ProfileEditScreen(nickname: 'BTS', repository: r.my),
|
||||
after: (t) async {
|
||||
await t.enterText(find.byType(TextField), 'BTS_Hong');
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
await _settle(t);
|
||||
},
|
||||
),
|
||||
Shot('F6-19', build: (r) async => InquiryScreen(repository: r.my)),
|
||||
// 시안 기본 상태는 기기 알림이 꺼진 쪽이다(켜진 쪽은 변형 `357:223611`).
|
||||
Shot(
|
||||
'F6-20',
|
||||
build: (r) async =>
|
||||
PushSettingsScreen(repository: r.my, deviceAllowed: false),
|
||||
),
|
||||
];
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
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 = _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 != 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();
|
||||
});
|
||||
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(),
|
||||
'size': p.text.style?.fontSize,
|
||||
'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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
@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/my/profile_edit_screen.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
|
||||
/// Figma 「계약 시점 사본」 화면과 나란히 놓을 앱 렌더를 뽑는다.
|
||||
///
|
||||
/// 실행: `DESIGN_MATCH_OUT=<폴더> fvm flutter test --tags design test/design_match` (단일: `--plain-name F6-01`)
|
||||
/// 출력: `$DESIGN_MATCH_OUT/<id>.png`. 변수가 없으면 일반 `flutter test` 에서 건너뛴다. 비교는 tools/design/compare.py.
|
||||
///
|
||||
/// 화면 ID·Figma 노드는 디자인 대조 자료(화면 목록 · 매핑 근거).
|
||||
/// 여기 없는 ID 는 앱 화면이 없거나(없음) 계약 제외(이메일·X·WeChat 가입 등)다.
|
||||
|
||||
/// 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,
|
||||
this.repos,
|
||||
});
|
||||
final String id;
|
||||
|
||||
/// 표본 저장소. 없으면 기본 표본(`Repositories(live: false)`).
|
||||
final Repositories Function()? repos;
|
||||
|
||||
/// 탭 밖 화면. [tab] 과 둘 중 하나만 준다.
|
||||
final Build? build;
|
||||
|
||||
/// 탭 루트 화면 — 실제 셸(하단 탭 포함)을 라우터로 띄운다.
|
||||
final String? tab;
|
||||
final double h;
|
||||
|
||||
/// 시트·다이얼로그·탭 전환처럼 화면을 띄운 뒤 한 번 더 조작해야 하는 상태.
|
||||
final After? after;
|
||||
}
|
||||
|
||||
/// 표본 저장소는 Future.value · 짧은 delay 만 쓴다. 무한 애니메이션이 있어 pumpAndSettle 은 쓰지 않는다.
|
||||
Future<void> _settle(WidgetTester t) async {
|
||||
for (var i = 0; i < 6; i++) {
|
||||
await t.pump(const Duration(milliseconds: 300));
|
||||
}
|
||||
}
|
||||
|
||||
/// 화면별 표본은 그룹 파일(a~e_test.dart)이 가진다 — 같은 ID 를 두 곳에서 그리면 실행 순서에 따라
|
||||
/// 결과가 바뀌므로, 여기에는 그룹 파일에 없는 ID 만 둔다(쓰레기통 F6-21 · 점수 제외).
|
||||
/// b_test 는 이 목록을 비우고 자기 표본을 채워 [main] 을 그대로 쓴다.
|
||||
final shots = <Shot>[
|
||||
Shot(
|
||||
'F6-21',
|
||||
build: (r) async =>
|
||||
ProfileEditScreen(nickname: 'BTS_Hong', repository: r.my),
|
||||
),
|
||||
];
|
||||
|
||||
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 = s.repos?.call() ?? 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import 'package:fancake/core/data/repositories.dart';
|
||||
import 'package:fancake/features/eclipse/eclipse_data.dart';
|
||||
import 'package:fancake/features/home/home_data.dart';
|
||||
|
||||
import '../eclipse_stubs.dart';
|
||||
|
||||
/// 디자인 대조용 HOME 표본 — 공유 하네스(`design_match_test.dart`)와 A 그룹(`a_test.dart`)이 같이 쓴다.
|
||||
|
||||
/// 사본 기본 홈 — 이클립스·라이브 이벤트 없음.
|
||||
class QuietRepos extends Repositories {
|
||||
QuietRepos() : super(live: false);
|
||||
|
||||
@override
|
||||
EclipseRepository get eclipse => const NoEclipseRepository();
|
||||
|
||||
@override
|
||||
HomeRepository get home => const QuietHome();
|
||||
}
|
||||
|
||||
/// 사본 `홈_성장 완료`(`344:213778`) — HOME 탭의 성운 상태. 성운 가입자라 ZAP 이 뜬다.
|
||||
class NebulaRepos extends QuietRepos {
|
||||
@override
|
||||
HomeRepository get home => const QuietHome(
|
||||
growth: PlanetGrowth(
|
||||
stage: 0,
|
||||
fandomName: 'BTS_RM',
|
||||
contributorName: 'BTS_Hong',
|
||||
contributedPop: 1240,
|
||||
growthPercent: 100,
|
||||
currentPop: 1240,
|
||||
requiredPop: 1000,
|
||||
members: 12,
|
||||
requiredMembers: 10,
|
||||
isFounder: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class QuietHome extends SampleHomeRepository {
|
||||
const QuietHome({this.growth});
|
||||
|
||||
final PlanetGrowth? growth;
|
||||
|
||||
@override
|
||||
Future<HomeSnapshot> load() async {
|
||||
final s = await super.load();
|
||||
return HomeSnapshot(
|
||||
popBalance: s.popBalance,
|
||||
liveTicker: null,
|
||||
fandomId: s.fandomId,
|
||||
growth: growth ?? s.growth,
|
||||
quests: s.quests,
|
||||
briefings: s.briefings,
|
||||
campaign: s.campaign,
|
||||
insides: s.insides,
|
||||
items: s.items,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user