generated from ttipo/project-base
feat(flutter): 명함 받기 — QR·코드로 받아 보관함에 저장
다른 회원이 공유한 펫 명함을 받아 보관할 수 있게 한다. - 스캐너(mobile_scanner) 또는 명함 코드 입력으로 명함을 찾고, 저장 전 확인 화면에서 메모를 남긴다 - 보관함 목록·검색·상세(보낸 사람이 공유한 모양 그대로)·삭제 - 공유 시트를 열 때 지금 보이는 모양(간략히·상세히)을 서버에 알린다 - 받기 전 사진은 명함 코드 경로(server-url:)로, 받은 뒤에는 내 소유 사본(server-file:)으로 그린다 - 기기 저장 모드에서는 받을 명함이 없으므로 안내만 띄운다 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:tma_app/core/api/file_uploader.dart';
|
||||
import 'package:tma_app/features/pet_card/data/api_pet_card_repository.dart';
|
||||
import 'package:tma_app/features/pet_card/data/received_card_repository.dart';
|
||||
import 'package:tma_app/features/pet_card/domain/card_view_mode.dart';
|
||||
import 'package:tma_app/features/pet_card/domain/received_card.dart';
|
||||
|
||||
import 'support/fake_api.dart';
|
||||
|
||||
Map<String, Object?> _petJson({bool detail = false}) => {
|
||||
'name': '초코',
|
||||
'species': '강아지',
|
||||
'breed': '닥스훈트',
|
||||
'gender': 'male',
|
||||
'birthDate': '2021-05-14',
|
||||
'weight': 7,
|
||||
'weightUnit': 'kg',
|
||||
'microchip': null,
|
||||
'tint': '#C9794A',
|
||||
if (detail) ...{
|
||||
'neutered': true,
|
||||
'bloodType': 'DEA 1.1-',
|
||||
'vet': '드림 동물병원',
|
||||
'memo': '공놀이를 좋아해요',
|
||||
'allergies': <String>[],
|
||||
'conditions': <String>[],
|
||||
'watchConditions': ['허리'],
|
||||
},
|
||||
};
|
||||
|
||||
const Map<String, Object?> _styleJson = {
|
||||
'tint': '#C9794A',
|
||||
'tint2': '#E2A878',
|
||||
'nameBg': null,
|
||||
'nameBgAsset': null,
|
||||
'textLight': false,
|
||||
'scrim': false,
|
||||
'scrimTone': 'light',
|
||||
};
|
||||
|
||||
Map<String, Object?> _receivedJson({
|
||||
String id = 'r1',
|
||||
String receivedAt = '2026-10-10T03:00:00.000Z',
|
||||
String? note,
|
||||
String mode = 'simple',
|
||||
String? petPhotoFileId,
|
||||
}) =>
|
||||
{
|
||||
'id': id,
|
||||
'code': '0428-117',
|
||||
'mode': mode,
|
||||
'senderNickname': '초코아빠',
|
||||
'senderPhotoFileId': null,
|
||||
'pet': _petJson(detail: mode == 'detail'),
|
||||
'petPhotoFileId': petPhotoFileId,
|
||||
'style': _styleJson,
|
||||
'backgroundFileId': null,
|
||||
'note': note,
|
||||
'receivedAt': receivedAt,
|
||||
};
|
||||
|
||||
void main() {
|
||||
group('parseCardCode', () {
|
||||
test('QR 주소·코드·하이픈 없는 7자리를 모두 명함 코드로 읽는다', () {
|
||||
expect(parseCardCode('rup://pet-card/0428-117'), '0428-117');
|
||||
expect(parseCardCode(' 0428-117 '), '0428-117');
|
||||
expect(parseCardCode('0428117'), '0428-117');
|
||||
});
|
||||
|
||||
test('명함 코드가 아니면 null', () {
|
||||
expect(parseCardCode('https://example.com'), isNull);
|
||||
expect(parseCardCode('0428-11'), isNull);
|
||||
expect(parseCardCode('rup://pet-card/../files'), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('ApiReceivedCardRepository', () {
|
||||
late FakeApi api;
|
||||
late ApiReceivedCardRepository repo;
|
||||
|
||||
setUp(() {
|
||||
api = FakeApi();
|
||||
repo = ApiReceivedCardRepository(dio: api.dio, session: api.session);
|
||||
});
|
||||
|
||||
test('미리보기 사진은 명함 코드 경로로 받는다 (아직 내 파일이 아니다)', () async {
|
||||
api.on('GET', '/shared-pet-cards/:code', (r) => ok({
|
||||
'code': r.params['code'],
|
||||
'mode': 'detail',
|
||||
'senderNickname': null,
|
||||
'hasSenderPhoto': false,
|
||||
'pet': _petJson(detail: true),
|
||||
'hasPetPhoto': true,
|
||||
'style': _styleJson,
|
||||
'hasBackgroundPhoto': true,
|
||||
}));
|
||||
|
||||
final card = await repo.preview('0428-117');
|
||||
|
||||
expect(card.mode, CardViewMode.detail);
|
||||
expect(card.senderName, kUnknownSender);
|
||||
expect(card.senderPhotoPath, isNull);
|
||||
expect(card.pet.photoPath, 'server-url:/shared-pet-cards/0428-117/images/pet');
|
||||
expect(card.style.nameBgImage, 'server-url:/shared-pet-cards/0428-117/images/background');
|
||||
expect(card.pet.vet, '드림 동물병원');
|
||||
expect(card.pet.watchConditions, ['허리']);
|
||||
});
|
||||
|
||||
test('보관함은 최근에 받은 명함이 앞이고, 간략히 명함은 건강 정보가 비어 있다', () async {
|
||||
api.on('GET', '/received-cards', (r) => ok(page([
|
||||
_receivedJson(),
|
||||
_receivedJson(id: 'r2', receivedAt: '2026-10-11T03:00:00.000Z', petPhotoFileId: 'f9'),
|
||||
])));
|
||||
|
||||
final cards = await repo.fetch();
|
||||
|
||||
expect(cards.map((c) => c.id), ['r2', 'r1']);
|
||||
expect(cards.first.pet.photoPath, 'server-file:f9');
|
||||
expect(cards.last.mode, CardViewMode.simple);
|
||||
expect(cards.last.pet.vet, isNull);
|
||||
expect(cards.last.pet.neutered, isFalse);
|
||||
});
|
||||
|
||||
test('받을 때 코드와 메모를 보낸다', () async {
|
||||
api.on('POST', '/received-cards', (r) {
|
||||
expect(r.json, {'code': '0428-117', 'note': '산책 친구'});
|
||||
return ok(_receivedJson(note: '산책 친구'), status: 201);
|
||||
});
|
||||
|
||||
final card = await repo.receive('0428-117', note: '산책 친구');
|
||||
|
||||
expect(card.note, '산책 친구');
|
||||
expect(card.senderName, '초코아빠');
|
||||
});
|
||||
|
||||
test('보관함에서 지운다', () async {
|
||||
api.on('DELETE', '/received-cards/:id', (r) => noContent);
|
||||
|
||||
await repo.delete('r1');
|
||||
|
||||
expect(api.sent('DELETE', '/received-cards/r1'), hasLength(1));
|
||||
});
|
||||
|
||||
test('로그인 전에는 보관함을 묻지 않는다', () async {
|
||||
final signedOut = FakeApi(tokens: null);
|
||||
final cards = await ApiReceivedCardRepository(dio: signedOut.dio, session: signedOut.session).fetch();
|
||||
|
||||
expect(cards, isEmpty);
|
||||
expect(signedOut.requests, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
test('공유 시트를 열면 받는 사람이 볼 모양을 서버에 알린다', () async {
|
||||
final api = FakeApi();
|
||||
api.on('PUT', '/pet-cards/:id/share-mode', (r) {
|
||||
expect(r.params['id'], 'c1');
|
||||
expect(r.json, {'mode': 'detail'});
|
||||
return noContent;
|
||||
});
|
||||
final repo = ApiPetCardRepository(dio: api.dio, session: api.session, uploader: FileUploader(api.dio, api.reported.add));
|
||||
|
||||
await repo.setShareMode('c1', CardViewMode.detail);
|
||||
|
||||
expect(api.sent('PUT', '/pet-cards/c1/share-mode'), hasLength(1));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:tma_app/core/api/api_exception.dart';
|
||||
import 'package:tma_app/features/pet_card/data/received_card_repository.dart';
|
||||
import 'package:tma_app/features/pet_card/domain/card_style.dart';
|
||||
import 'package:tma_app/features/pet_card/domain/card_view_mode.dart';
|
||||
import 'package:tma_app/features/pet_card/domain/received_card.dart';
|
||||
import 'package:tma_app/features/pet_card/presentation/pages/card_scanner_page.dart';
|
||||
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
|
||||
|
||||
import 'support/app_harness.dart';
|
||||
|
||||
const String _kCode = '0428-117';
|
||||
|
||||
SharedCard _shared({CardViewMode mode = CardViewMode.simple}) => SharedCard(
|
||||
code: _kCode,
|
||||
mode: mode,
|
||||
senderNickname: '초코아빠',
|
||||
senderPhotoPath: null,
|
||||
pet: samplePet(id: 'shared', name: '초코'),
|
||||
style: CardStyle.initial(),
|
||||
);
|
||||
|
||||
ReceivedCard _received(
|
||||
String id, {
|
||||
String name = '초코',
|
||||
String sender = '초코아빠',
|
||||
String? note,
|
||||
CardViewMode mode = CardViewMode.simple,
|
||||
}) =>
|
||||
ReceivedCard(
|
||||
id: id,
|
||||
code: _kCode,
|
||||
mode: mode,
|
||||
senderNickname: sender,
|
||||
senderPhotoPath: null,
|
||||
pet: samplePet(id: 'received-$id', name: name),
|
||||
style: CardStyle.initial(),
|
||||
note: note,
|
||||
receivedAt: DateTime(2026, 10, 9),
|
||||
);
|
||||
|
||||
/// 서버 없이 받은 명함을 기억하는 저장소
|
||||
class _FakeReceivedRepo implements ReceivedCardRepository {
|
||||
_FakeReceivedRepo({List<ReceivedCard> cards = const [], this.failure}) : cards = [...cards];
|
||||
|
||||
final List<ReceivedCard> cards;
|
||||
|
||||
/// 미리보기를 이 코드로 실패시킨다 (인터셉터가 알린 실패처럼)
|
||||
final String? failure;
|
||||
final previewed = <String>[];
|
||||
String? savedNote;
|
||||
|
||||
@override
|
||||
bool get available => true;
|
||||
|
||||
@override
|
||||
Future<List<ReceivedCard>> fetch() async => [...cards];
|
||||
|
||||
@override
|
||||
Future<SharedCard> preview(String code) async {
|
||||
previewed.add(code);
|
||||
if (failure case final code?) throw ApiException.fromCode(code);
|
||||
return _shared();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ReceivedCard> receive(String code, {String? note}) async {
|
||||
savedNote = note;
|
||||
final card = _received('new', note: note);
|
||||
cards.insert(0, card);
|
||||
return card;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> delete(String id) async => cards.removeWhere((c) => c.id == id);
|
||||
}
|
||||
|
||||
/// 실제 카메라 대신 — 누르면 QR 을 읽은 것처럼 알린다
|
||||
Widget _fakeCamera(ValueChanged<String> onCode) => Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 120),
|
||||
child: TextButton(onPressed: () => onCode('rup://pet-card/$_kCode'), child: const Text('QR 비추기')),
|
||||
),
|
||||
);
|
||||
|
||||
Future<void> _openCardsTab(WidgetTester tester, ReceivedCardRepository repo) async {
|
||||
await launchSignedIn(
|
||||
tester,
|
||||
overrides: [
|
||||
receivedCardRepositoryProvider.overrideWithValue(repo),
|
||||
scannerCameraProvider.overrideWithValue(_fakeCamera),
|
||||
],
|
||||
);
|
||||
await tester.tap(find.bySemanticsLabel('펫 명함'));
|
||||
await settle(tester);
|
||||
}
|
||||
|
||||
Future<void> _openScanner(WidgetTester tester) async {
|
||||
await tester.ensureVisible(find.text('명함 받기'));
|
||||
await tester.tap(find.text('명함 받기'));
|
||||
await settle(tester);
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('보관함은 받은 명함 수와 보낸 사람을 보여 주고 검색으로 거른다', (tester) async {
|
||||
await _openCardsTab(
|
||||
tester,
|
||||
_FakeReceivedRepo(cards: [_received('r1'), _received('r2', name: '나비', sender: '나비집사')]),
|
||||
);
|
||||
|
||||
expect(find.text('보관된 명함 2'), findsOneWidget);
|
||||
expect(find.text('초코'), findsOneWidget);
|
||||
expect(find.textContaining('님이 보냈어요.'), findsNWidgets(2));
|
||||
|
||||
// 돋보기를 누르면 검색창이 펼쳐진다
|
||||
await tester.tap(find.byWidgetPredicate((w) => w is RupIcon && w.name == RupIconName.search));
|
||||
await settle(tester);
|
||||
await tester.enterText(find.byType(TextField), '나비집사');
|
||||
await settle(tester);
|
||||
expect(find.text('나비'), findsOneWidget);
|
||||
expect(find.text('초코'), findsNothing);
|
||||
|
||||
await tester.enterText(find.byType(TextField), '보리');
|
||||
await settle(tester);
|
||||
expect(find.text("'보리' 검색 결과가 없어요."), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('QR 을 비추면 명함을 불러와 메모와 함께 보관함에 저장한다', (tester) async {
|
||||
final repo = _FakeReceivedRepo();
|
||||
await _openCardsTab(tester, repo);
|
||||
await _openScanner(tester);
|
||||
expect(find.text('상대의 펫 명함 QR 코드를\n사각형 안에 비춰주세요.'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('QR 비추기'));
|
||||
await tester.pump();
|
||||
expect(find.text('명함을 찾았어요! 정보 불러오는 중…'), findsOneWidget);
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
await settle(tester);
|
||||
|
||||
expect(repo.previewed, [_kCode]);
|
||||
expect(find.text('초코의 명함'), findsOneWidget);
|
||||
expect(find.text('초코아빠님'), findsOneWidget);
|
||||
|
||||
await tester.enterText(find.byType(TextField), '산책 친구');
|
||||
await tester.pump();
|
||||
expect(find.text('5/200'), findsOneWidget);
|
||||
await tester.ensureVisible(find.text('보관함에 저장'));
|
||||
await tester.tap(find.text('보관함에 저장'));
|
||||
await settle(tester);
|
||||
|
||||
expect(repo.savedNote, '산책 친구');
|
||||
expect(find.text('초코 명함을 보관함에 저장했어요'), findsOneWidget);
|
||||
expect(find.text('보관된 명함 1'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('명함 코드를 직접 입력해도 받을 수 있다', (tester) async {
|
||||
final repo = _FakeReceivedRepo();
|
||||
await _openCardsTab(tester, repo);
|
||||
await _openScanner(tester);
|
||||
|
||||
await tester.tap(find.text('명함 코드 입력하기'));
|
||||
await settle(tester);
|
||||
expect(find.text('명함코드 입력하기'), findsOneWidget);
|
||||
|
||||
await tester.enterText(find.byType(TextField), '0428117');
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('보관함에 저장'));
|
||||
await settle(tester);
|
||||
|
||||
expect(repo.previewed, [_kCode]);
|
||||
expect(find.text('초코의 명함'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('명함 코드 모양이 아니면 서버에 묻지 않고 알린다', (tester) async {
|
||||
final repo = _FakeReceivedRepo();
|
||||
await _openCardsTab(tester, repo);
|
||||
await _openScanner(tester);
|
||||
await tester.tap(find.text('명함 코드 입력하기'));
|
||||
await settle(tester);
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'PET-1A2B');
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('보관함에 저장'));
|
||||
await settle(tester);
|
||||
|
||||
expect(repo.previewed, isEmpty);
|
||||
expect(find.text('명함을 찾을 수 없어요. 명함 코드를 다시 확인해 주세요.'), findsOneWidget);
|
||||
expect(find.text('명함코드 입력하기'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('명함을 받지 못하면 스캐너로 돌아가 다시 비출 수 있다', (tester) async {
|
||||
final repo = _FakeReceivedRepo(failure: 'CARD_003');
|
||||
await _openCardsTab(tester, repo);
|
||||
await _openScanner(tester);
|
||||
|
||||
await tester.tap(find.text('QR 비추기'));
|
||||
await settle(tester);
|
||||
|
||||
expect(repo.previewed, [_kCode]);
|
||||
expect(find.text('상대의 펫 명함 QR 코드를\n사각형 안에 비춰주세요.'), findsOneWidget);
|
||||
expect(find.text('명함 코드 입력하기'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('받은 명함은 보낸 모양대로 보이고 보관함에서 지울 수 있다', (tester) async {
|
||||
final repo = _FakeReceivedRepo(cards: [_received('r1')]);
|
||||
await _openCardsTab(tester, repo);
|
||||
|
||||
await tester.ensureVisible(find.text('초코'));
|
||||
await tester.tap(find.text('초코'));
|
||||
await settle(tester);
|
||||
|
||||
expect(find.text('받은 명함 · 초코아빠님'), findsOneWidget);
|
||||
// 간략히로 공유된 명함 — 상세 명함(건강 정보)은 없다
|
||||
expect(find.text('건강 정보'), findsNothing);
|
||||
expect(find.text('남긴 메모가 없어요.'), findsOneWidget);
|
||||
|
||||
await tester.ensureVisible(find.text('보관함에서 삭제'));
|
||||
await tester.tap(find.text('보관함에서 삭제'));
|
||||
await settle(tester);
|
||||
|
||||
expect(repo.cards, isEmpty);
|
||||
expect(find.text('보관함에서 삭제했어요'), findsOneWidget);
|
||||
expect(find.text('받은 명함이 없어요.'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('기기 저장 모드에서는 명함 받기 대신 안내를 띄운다', (tester) async {
|
||||
await _openCardsTab(tester, const UnavailableReceivedCardRepository());
|
||||
await _openScanner(tester);
|
||||
|
||||
expect(find.text('기기 저장 모드에서는 명함을 받을 수 없어요'), findsOneWidget);
|
||||
expect(find.text('명함 코드 입력하기'), findsNothing);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user