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

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
+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);
});
}
}