앱 단위·화면·통합 테스트
This commit is contained in:
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user