Skip to content
4 changes: 4 additions & 0 deletions lib/Screen/PracticeNote/PracticeCompletionScreen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,10 @@ class _PracticeCompletionScreenState extends State<PracticeCompletionScreen> {
onPressed: _submitting
? null
: () async {
// 버튼을 잠그는 것은 다시 그린 뒤부터라, 같은 프레임에 두
// 번 눌리면 이 콜백이 두 번 돈다. 복습 횟수가 두 번 오르는
// 자리라 눌린 순간에도 확인한다.
if (_submitting) return;
final navigator = Navigator.of(context);
final missionProvider =
Provider.of<MissionProvider>(context, listen: false);
Expand Down
4 changes: 4 additions & 0 deletions lib/Screen/PracticeNote/PracticeDetailLoader.dart
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ class _PracticeDetailLoaderState extends State<PracticeDetailLoader> {

try {
await provider.fetchPracticeNote(widget.practiceId);
// 세트를 받는 동안 사용자가 나가서 다른 세트를 열었을 수 있다. 그때
// 이어서 moveToPractice 를 부르면 화면에 떠 있는 다른 세트의 문제
// 목록을 이 세트 것으로 덮어써서, 복습 횟수도 엉뚱한 세트에 올라간다.
if (!mounted) return;
await provider.moveToPractice(widget.practiceId);
} catch (error) {
debugPrint('Failed to open practice ${widget.practiceId}: $error');
Expand Down
17 changes: 13 additions & 4 deletions lib/Screen/StudyRoom/StudyRoomDetailScreen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,10 @@ class _StudyRoomDetailScreenState extends State<StudyRoomDetailScreen>
bool isHost = false,
}) async {
final themeProvider = Provider.of<ThemeHandler>(context, listen: false);
final room = provider.selectedRoom;
final selected = provider.selectedRoom;
// 다른 방이 남아 있을 수 있어 번호를 확인한다 (build 와 같은 이유).
final room =
selected != null && selected.roomId == widget.roomId ? selected : null;
final hasOtherMembers = room != null && room.members.length > 1;
final content = isHost
? hasOtherMembers
Expand Down Expand Up @@ -324,7 +327,13 @@ class _StudyRoomDetailScreenState extends State<StudyRoomDetailScreen>
Widget build(BuildContext context) {
final provider = Provider.of<StudyRoomProvider>(context);
final themeProvider = Provider.of<ThemeHandler>(context);
final room = provider.selectedRoom;
// 프로바이더는 방을 하나만 들고 있고, 조회에 실패하면 앞서 열었던 방이
// 그대로 남는다. 번호가 다르면 없는 것으로 본다. 예전에는 로딩 중일 때만
// 번호를 봐서, 조회가 실패하면 앞 방의 내용을 그리면서 나가기와 삭제는
// 이 화면의 방 번호로 나갔다.
final selected = provider.selectedRoom;
final room =
selected != null && selected.roomId == widget.roomId ? selected : null;
final isHost = room != null && provider.isHost(room);

return Scaffold(
Expand Down Expand Up @@ -381,7 +390,7 @@ class _StudyRoomDetailScreenState extends State<StudyRoomDetailScreen>
child: SizedBox.shrink(),
),
),
body: provider.isLoading && (room == null || room.roomId != widget.roomId)
body: provider.isLoading && room == null
? Center(
child: CircularProgressIndicator(
color: themeProvider.primaryColor,
Expand All @@ -390,7 +399,7 @@ class _StudyRoomDetailScreenState extends State<StudyRoomDetailScreen>
: room == null
? Center(
child: StandardText(
text: '방을 찾을 수 없습니다',
text: '방을 불러오지 못했어요',
fontSize: 15,
color: Colors.grey[500]!,
),
Expand Down
20 changes: 18 additions & 2 deletions lib/Screen/User/MyPageScreen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -550,7 +550,17 @@ class _MyPageSettingsScreenState extends State<_MyPageSettingsScreen> {
'로그아웃',
'정말 로그아웃 하시겠습니까?\n(게스트 유저의 경우 모든 정보가 삭제됩니다.)',
() async {
await userProvider.signOut();
// 게스트는 로그아웃이 곧 계정 삭제라 서버 요청이 나간다.
// 실패하면 로그아웃되지 않은 것이므로 알리고 화면을 두어야
// 한다. 예전에는 예외를 아무도 받지 않아 아무 반응 없이
// 멈춘 것처럼 보였다.
try {
await userProvider.signOut();
} catch (error) {
debugPrint('로그아웃 실패: $error');
AppToast.error('로그아웃에 실패했어요. 잠시 후 다시 시도해주세요.');
return;
}
screenIndexProvider.setSelectedIndex(0);

if (!context.mounted) return;
Expand All @@ -565,7 +575,13 @@ class _MyPageSettingsScreenState extends State<_MyPageSettingsScreen> {
'회원 탈퇴',
'정말 회원 탈퇴 하시겠습니까?\n그동안 작성했던 모든 오답노트 및 개인정보가 삭제됩니다. 이 작업은 되돌릴 수 없습니다.',
() async {
await userProvider.deleteAccount();
try {
await userProvider.deleteAccount();
} catch (error) {
debugPrint('회원 탈퇴 실패: $error');
AppToast.error('회원 탈퇴에 실패했어요. 잠시 후 다시 시도해주세요.');
return;
}
screenIndexProvider.setSelectedIndex(0);

if (!context.mounted) return;
Expand Down
14 changes: 13 additions & 1 deletion lib/Service/SocialLogin/KakaoAuthService.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import 'package:kakao_flutter_sdk/kakao_flutter_sdk.dart';
import 'package:ono/Model/User/UserRegisterModel.dart';
import 'package:sentry_flutter/sentry_flutter.dart';

import '../../Util/AppSnackBar.dart';

class KakaoAuthService {
Future<UserRegisterModel?> signInWithKakao(BuildContext context) async {
if (await isKakaoTalkInstalled()) {
Expand Down Expand Up @@ -83,7 +85,17 @@ class KakaoAuthService {
await UserApi.instance.logout();
}

/// 카카오 연동을 끊는다.
///
/// 실패해도 던지지 않는다. 탈퇴는 서버 계정을 지우는 것이 본체라, 카카오
/// 쪽 정리가 안 됐다고 탈퇴 자체를 멈추면 계정이 남는다. 구글, 애플과 같다.
Future<void> revokeKakaoSignIn() async {
await UserApi.instance.unlink();
try {
await UserApi.instance.unlink();
} catch (error, stackTrace) {
debugPrint('카카오 연동 해제 실패: $error');
AppSnackBar.showError('카카오 계정 연동 해제에 실패했습니다.');
await Sentry.captureException(error, stackTrace: stackTrace);
}
}
}
17 changes: 17 additions & 0 deletions test/screen/practice_note/practice_completion_screen_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,23 @@ void main() {
expect(find.text('복습을 완료했습니다!'), findsOneWidget);
});

// 완료 요청은 보낼 때마다 복습 횟수를 하나씩 올린다. 버튼이 잠기는 것은 화면을
// 다시 그린 뒤라, 같은 프레임에 두 번 눌려도 한 번만 나가야 한다.
testWidgets('확인 버튼을 연달아 두 번 눌러도 한 번만 저장한다', (tester) async {
when(() => practiceNoteService.addPracticeNoteCount(1, moodEmojiKey: null))
.thenAnswer((_) async {});

await pumpScreen(tester);
await tester.tap(find.text('확인'));
await tester.tap(find.text('확인'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));

verify(() =>
practiceNoteService.addPracticeNoteCount(1, moodEmojiKey: null))
.called(1);
});

testWidgets('기분을 고르고 확인을 누르면 선택한 moodEmojiKey 로 저장한다', (tester) async {
when(() => practiceNoteService.addPracticeNoteCount(1,
moodEmojiKey: any(named: 'moodEmojiKey'))).thenAnswer((_) async {});
Expand Down
115 changes: 115 additions & 0 deletions test/screen/practice_note/practice_detail_loader_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// PracticeDetailLoader 위젯 테스트.
//
// 목록에서 세트를 누르면 화면부터 넘기고 이 로더 안에서 불러온다. 불러오는
// 동안 사용자가 나가서 다른 세트를 열 수 있는데, 그때 늦게 끝난 쪽이 공용
// 상태를 덮으면 화면에 떠 있는 세트에 다른 세트의 문제가 들어간다.
import 'dart:async';

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:ono/Model/PracticeNote/PracticeNoteDetailModel.dart';
import 'package:ono/Model/Problem/ProblemModel.dart';
import 'package:ono/Module/Motion/Skeleton.dart';
import 'package:ono/Provider/PracticeNoteProvider.dart';
import 'package:ono/Screen/PracticeNote/PracticeDetailLoader.dart';

import '../../helpers/helpers.dart';

PracticeNoteDetailModel _practice({
required int practiceId,
List<int> problemIdList = const [],
}) {
return PracticeNoteDetailModel(
practiceId: practiceId,
practiceTitle: '수학 오답노트',
practiceCount: 0,
createdAt: DateTime(2024, 1, 1),
lastSolvedAt: null,
problemIdList: problemIdList,
);
}

ProblemModel _problem(int id) => ProblemModel(
problemId: id,
createdAt: DateTime(2024, 1, 1),
);

void main() {
setUpOnoWidgetTest();

late MockPracticeNoteService practiceNoteService;
late MockProblemsProvider problemsProvider;
late ProblemPracticeProvider practiceProvider;

setUp(() {
practiceNoteService = MockPracticeNoteService();
problemsProvider = MockProblemsProvider();
practiceProvider = ProblemPracticeProvider(
problemsProvider: problemsProvider,
practiceNoteService: practiceNoteService,
);
});

testWidgets('세트를 불러오는 동안에는 뼈대가 보인다', (tester) async {
final completer = Completer<PracticeNoteDetailModel>();
when(() => practiceNoteService.getPracticeNoteById(1,
showErrorSnackBar: any(named: 'showErrorSnackBar'))).thenAnswer(
(_) => completer.future,
);

await withMockedNetworkImages(() async {
await pumpOnoWidget(
tester,
const PracticeDetailLoader(practiceId: 1, title: '수학 오답노트'),
practiceProvider: practiceProvider,
settle: false,
);
});

expect(find.byType(SkeletonList), findsOneWidget);
// 세트 이름은 목록에서 이미 알고 있어서 불러오기 전에도 보인다.
expect(find.text('수학 오답노트'), findsOneWidget);

completer.complete(_practice(practiceId: 1));
await tester.pump();
});

testWidgets('불러오는 중에 화면을 나가면 다른 세트의 문제를 덮지 않는다', (tester) async {
final completer = Completer<PracticeNoteDetailModel>();
when(() => practiceNoteService.getPracticeNoteById(1,
showErrorSnackBar: any(named: 'showErrorSnackBar'))).thenAnswer(
(_) => completer.future,
);
when(() => problemsProvider.getProblem(any()))
.thenAnswer((invocation) async => _problem(
invocation.positionalArguments.first as int,
));

// 화면에는 이미 다른 세트(2번)가 떠 있는 상황을 만든다.
practiceProvider.currentPracticeNote = _practice(practiceId: 2);
practiceProvider.currentProblems = [_problem(99)];

await withMockedNetworkImages(() async {
await pumpOnoWidget(
tester,
const PracticeDetailLoader(practiceId: 1, title: '수학 오답노트'),
practiceProvider: practiceProvider,
settle: false,
);
});

// 사용자가 나가서 로더가 사라진 뒤에 1번 세트 응답이 도착한다.
await tester.pumpWidget(const SizedBox());
completer.complete(_practice(practiceId: 1, problemIdList: [10]));
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));

expect(practiceProvider.currentPracticeNote?.practiceId, 2);
expect(
practiceProvider.currentProblems.map((p) => p.problemId),
[99],
reason: '죽은 로더가 화면에 떠 있는 세트의 문제 목록을 덮으면 복습 횟수도 엉뚱한 세트에 올라간다',
);
});
}
24 changes: 23 additions & 1 deletion test/screen/study_room/study_room_detail_screen_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -124,10 +124,32 @@ void main() {

await pumpDetail(tester, currentUserId: 10);

expect(find.text('방을 찾을 수 없습니다'), findsOneWidget);
expect(find.text('방을 불러오지 못했어요'), findsOneWidget);
expect(find.byType(CircularProgressIndicator), findsNothing);
});

// 프로바이더는 방을 하나만 들고 있고 조회에 실패하면 앞서 열었던 방이 남는다.
// 그 방을 그리면 제목과 멤버는 앞 방인데 나가기와 삭제는 이 화면의 방 번호로
// 나가서, 사용자가 보고 있지도 않은 방에서 나가게 된다.
testWidgets('앞서 연 방이 남아 있어도 다른 방 조회가 실패하면 그 방을 그리지 않는다', (tester) async {
when(() => service.fetchRoomDetail(1)).thenAnswer((_) async => _room());
await pumpDetail(tester, currentUserId: 10);
expect(find.text('알고리즘 스터디'), findsWidgets);

when(() => service.fetchRoomDetail(2)).thenThrow(Exception('forbidden'));
await withMockedNetworkImages(() async {
await pumpOnoWidget(
tester,
const StudyRoomDetailScreen(roomId: 2),
studyRoomProvider: studyRoomProvider,
userProvider: _buildUserProvider(10),
);
});

expect(find.text('알고리즘 스터디'), findsNothing);
expect(find.text('방을 불러오지 못했어요'), findsOneWidget);
});

testWidgets('정상 응답이면 방 이름과 멤버 수, 탭 4개가 보인다', (tester) async {
when(() => service.fetchRoomDetail(1)).thenAnswer((_) async => _room());

Expand Down
23 changes: 23 additions & 0 deletions test/screen/user/my_page_screen_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,29 @@ void main() {
verify(() => userProvider.signOut()).called(1);
expect(find.byType(LoginScreen), findsOneWidget);
});

// 게스트는 로그아웃이 곧 계정 삭제라 서버 요청이 나간다. 실패했는데도
// 로그인 화면으로 넘어가면 지워지지 않은 계정에서 빠져나간 셈이 된다.
testWidgets('로그아웃에 실패하면 안내를 띄우고 화면을 넘기지 않는다', (tester) async {
final userProvider = buildLoggedInUserProvider();
when(() => userProvider.signOut()).thenThrow(Exception('network'));

await withMockedNetworkImages(() async {
await pumpOnoWidget(
tester,
const SettingScreen(),
userProvider: userProvider,
);
});

await openAccountActionDialog(tester, '로그아웃');

await tester.tap(find.text('확인'));
await tester.pumpAndSettle();

expect(find.byType(LoginScreen), findsNothing);
expect(find.textContaining('로그아웃에 실패했어요'), findsOneWidget);
});
});

group('회원 탈퇴 — 되돌릴 수 없는 동작 확인', () {
Expand Down
Loading