앱 단위·화면·통합 테스트

This commit is contained in:
Sn-Kinos
2026-10-04 01:43:10 +09:00
parent de44739e63
commit 4f6e1eac61
63 changed files with 14700 additions and 230 deletions
+270
View File
@@ -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);
});
}
}
+308
View File
@@ -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)),
];
+499
View File
@@ -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);
});
}
}
+415
View File
@@ -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);
});
}
}
+328
View File
@@ -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);
});
}
}
+59
View File
@@ -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,
);
}
}