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

500 lines
18 KiB
Dart

@Tags(['design'])
library;
import 'dart:convert';
import 'dart:io';
import 'dart:ui' as ui;
import 'package:fancake/core/data/repositories.dart';
import 'package:fancake/core/router/app_router.dart';
import 'package:fancake/core/theme/app_theme.dart';
import 'package:fancake/features/auth/auth_repository.dart';
import 'package:fancake/features/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 => PlacePickerScreen(mapBuilder: (_) => const SizedBox.expand())),
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);
});
}
}