diff --git a/lib/Screen/PracticeNote/PracticeCompletionScreen.dart b/lib/Screen/PracticeNote/PracticeCompletionScreen.dart index 8ebb7b71..9bab57fb 100644 --- a/lib/Screen/PracticeNote/PracticeCompletionScreen.dart +++ b/lib/Screen/PracticeNote/PracticeCompletionScreen.dart @@ -243,6 +243,10 @@ class _PracticeCompletionScreenState extends State { onPressed: _submitting ? null : () async { + // 버튼을 잠그는 것은 다시 그린 뒤부터라, 같은 프레임에 두 + // 번 눌리면 이 콜백이 두 번 돈다. 복습 횟수가 두 번 오르는 + // 자리라 눌린 순간에도 확인한다. + if (_submitting) return; final navigator = Navigator.of(context); final missionProvider = Provider.of(context, listen: false); diff --git a/lib/Screen/PracticeNote/PracticeDetailLoader.dart b/lib/Screen/PracticeNote/PracticeDetailLoader.dart index faaa8c17..94fa10a6 100644 --- a/lib/Screen/PracticeNote/PracticeDetailLoader.dart +++ b/lib/Screen/PracticeNote/PracticeDetailLoader.dart @@ -55,6 +55,10 @@ class _PracticeDetailLoaderState extends State { 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'); diff --git a/lib/Screen/StudyRoom/StudyRoomDetailScreen.dart b/lib/Screen/StudyRoom/StudyRoomDetailScreen.dart index c34e2203..4c2b72d6 100644 --- a/lib/Screen/StudyRoom/StudyRoomDetailScreen.dart +++ b/lib/Screen/StudyRoom/StudyRoomDetailScreen.dart @@ -164,7 +164,10 @@ class _StudyRoomDetailScreenState extends State bool isHost = false, }) async { final themeProvider = Provider.of(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 @@ -324,7 +327,13 @@ class _StudyRoomDetailScreenState extends State Widget build(BuildContext context) { final provider = Provider.of(context); final themeProvider = Provider.of(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( @@ -381,7 +390,7 @@ class _StudyRoomDetailScreenState extends State child: SizedBox.shrink(), ), ), - body: provider.isLoading && (room == null || room.roomId != widget.roomId) + body: provider.isLoading && room == null ? Center( child: CircularProgressIndicator( color: themeProvider.primaryColor, @@ -390,7 +399,7 @@ class _StudyRoomDetailScreenState extends State : room == null ? Center( child: StandardText( - text: '방을 찾을 수 없습니다', + text: '방을 불러오지 못했어요', fontSize: 15, color: Colors.grey[500]!, ), diff --git a/lib/Screen/User/MyPageScreen.dart b/lib/Screen/User/MyPageScreen.dart index ed1badff..e0f131b1 100644 --- a/lib/Screen/User/MyPageScreen.dart +++ b/lib/Screen/User/MyPageScreen.dart @@ -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; @@ -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; diff --git a/lib/Service/SocialLogin/KakaoAuthService.dart b/lib/Service/SocialLogin/KakaoAuthService.dart index a2c7bf4d..a5c1e151 100644 --- a/lib/Service/SocialLogin/KakaoAuthService.dart +++ b/lib/Service/SocialLogin/KakaoAuthService.dart @@ -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 signInWithKakao(BuildContext context) async { if (await isKakaoTalkInstalled()) { @@ -83,7 +85,17 @@ class KakaoAuthService { await UserApi.instance.logout(); } + /// 카카오 연동을 끊는다. + /// + /// 실패해도 던지지 않는다. 탈퇴는 서버 계정을 지우는 것이 본체라, 카카오 + /// 쪽 정리가 안 됐다고 탈퇴 자체를 멈추면 계정이 남는다. 구글, 애플과 같다. Future revokeKakaoSignIn() async { - await UserApi.instance.unlink(); + try { + await UserApi.instance.unlink(); + } catch (error, stackTrace) { + debugPrint('카카오 연동 해제 실패: $error'); + AppSnackBar.showError('카카오 계정 연동 해제에 실패했습니다.'); + await Sentry.captureException(error, stackTrace: stackTrace); + } } } diff --git a/test/screen/practice_note/practice_completion_screen_test.dart b/test/screen/practice_note/practice_completion_screen_test.dart index 01c87d52..1b12bda7 100644 --- a/test/screen/practice_note/practice_completion_screen_test.dart +++ b/test/screen/practice_note/practice_completion_screen_test.dart @@ -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 {}); diff --git a/test/screen/practice_note/practice_detail_loader_test.dart b/test/screen/practice_note/practice_detail_loader_test.dart new file mode 100644 index 00000000..c877ed6f --- /dev/null +++ b/test/screen/practice_note/practice_detail_loader_test.dart @@ -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 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(); + 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(); + 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: '죽은 로더가 화면에 떠 있는 세트의 문제 목록을 덮으면 복습 횟수도 엉뚱한 세트에 올라간다', + ); + }); +} diff --git a/test/screen/study_room/study_room_detail_screen_test.dart b/test/screen/study_room/study_room_detail_screen_test.dart index 9c42e369..a52f4c5c 100644 --- a/test/screen/study_room/study_room_detail_screen_test.dart +++ b/test/screen/study_room/study_room_detail_screen_test.dart @@ -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()); diff --git a/test/screen/user/my_page_screen_test.dart b/test/screen/user/my_page_screen_test.dart index 1f135fec..c62e5fbf 100644 --- a/test/screen/user/my_page_screen_test.dart +++ b/test/screen/user/my_page_screen_test.dart @@ -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('회원 탈퇴 — 되돌릴 수 없는 동작 확인', () {