From 683735643ef7e2823d137f7021b3a173cc1b0c18 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Fri, 25 Sep 2026 15:24:02 +0900 Subject: [PATCH 1/4] =?UTF-8?q?[Fix]=20=ED=83=88=ED=87=B4=EC=99=80=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=95=84=EC=9B=83=EC=9D=B4=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=ED=95=B4=EB=8F=84=20=EC=95=84=EB=AC=B4=20=EB=A7=90=20?= =?UTF-8?q?=EC=97=86=EC=9D=B4=20=EB=A9=88=EC=B6=94=EB=8D=98=20=EA=B2=83=20?= =?UTF-8?q?-=20loginMethod=20=EB=8C=80=EC=86=8C=EB=AC=B8=EC=9E=90=EB=A5=BC?= =?UTF-8?q?=20=EB=A7=9E=EC=B6=98=20=EB=92=A4=EB=A1=9C=20=EA=B2=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EB=A1=9C=EA=B7=B8=EC=95=84=EC=9B=83=EC=9D=B4=20?= =?UTF-8?q?=EC=8B=A4=EC=A0=9C=EB=A1=9C=20=EA=B3=84=EC=A0=95=EC=9D=84=20?= =?UTF-8?q?=EC=A7=80=EC=9A=B0=EA=B3=A0=20=EC=86=8C=EC=85=9C=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99=20=ED=95=B4=EC=A0=9C=EB=8F=84=20=EC=8B=A4=EC=A0=9C?= =?UTF-8?q?=EB=A1=9C=20=EB=8F=84=EB=8A=94=EB=8D=B0,=20=EC=B9=B4=EC=B9=B4?= =?UTF-8?q?=EC=98=A4=20unlink=20=EB=A7=8C=20=EC=98=88=EC=99=B8=EB=A5=BC=20?= =?UTF-8?q?=EA=B0=90=EC=8B=B8=EC=A7=80=20=EC=95=8A=EC=95=84=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=ED=95=98=EB=A9=B4=20=EC=84=9C=EB=B2=84=20=ED=83=88?= =?UTF-8?q?=ED=87=B4=EA=B9=8C=EC=A7=80=20=EB=A9=88=EC=B7=84=EB=8B=A4,=20?= =?UTF-8?q?=EB=A7=88=EC=9D=B4=ED=8E=98=EC=9D=B4=EC=A7=80=EC=9D=98=20?= =?UTF-8?q?=EB=91=90=20=EC=BD=9C=EB=B0=B1=EC=9D=80=20async=20=EB=A5=BC=20V?= =?UTF-8?q?oidCallback=20=EC=9E=90=EB=A6=AC=EC=97=90=20=EB=84=98=EA=B2=A8?= =?UTF-8?q?=EC=84=9C=20=EC=98=88=EC=99=B8=EB=A5=BC=20=EC=95=84=EB=AC=B4?= =?UTF-8?q?=EB=8F=84=20=EB=B0=9B=EC=A7=80=20=EB=AA=BB=ED=95=B4=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=EC=9D=80=20=EA=B7=B8=EB=8C=80=EB=A1=9C=EA=B3=A0=20Sen?= =?UTF-8?q?try=20=EC=97=90=EB=A7=8C=20fatal=20=EB=A1=9C=20=EC=8C=93?= =?UTF-8?q?=EC=98=80=EB=8B=A4,=20=EA=B5=AC=EA=B8=80=20=EC=95=A0=ED=94=8C?= =?UTF-8?q?=EC=B2=98=EB=9F=BC=20=EC=B9=B4=EC=B9=B4=EC=98=A4=EB=8F=84=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=EB=A5=BC=20=EC=82=BC=EC=BC=9C=20=ED=83=88?= =?UTF-8?q?=ED=87=B4=EB=A5=BC=20=EC=9D=B4=EC=96=B4=EA=B0=80=EA=B3=A0=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=EC=9D=80=20=EC=8B=A4=ED=8C=A8=EB=A5=BC=20?= =?UTF-8?q?=EC=95=8C=EB=A6=B0=20=EB=92=A4=20=EB=84=98=EC=96=B4=EA=B0=80?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/Screen/User/MyPageScreen.dart | 20 ++++++++++++++-- lib/Service/SocialLogin/KakaoAuthService.dart | 14 ++++++++++- test/screen/user/my_page_screen_test.dart | 23 +++++++++++++++++++ 3 files changed, 54 insertions(+), 3 deletions(-) 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/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('회원 탈퇴 — 되돌릴 수 없는 동작 확인', () { From 364d136ee8f4239e4cda96cfea246ac70f6b12f6 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Fri, 25 Sep 2026 15:24:02 +0900 Subject: [PATCH 2/4] =?UTF-8?q?[Fix]=20=EC=8A=A4=ED=84=B0=EB=94=94?= =?UTF-8?q?=EB=A3=B8=20=EC=A1=B0=ED=9A=8C=EC=97=90=20=EC=8B=A4=ED=8C=A8?= =?UTF-8?q?=ED=95=98=EB=A9=B4=20=EC=95=9E=EC=84=9C=20=EC=97=B4=EC=97=88?= =?UTF-8?q?=EB=8D=98=20=EB=B0=A9=EC=9D=84=20=EA=B7=B8=EB=A6=AC=EB=8D=98=20?= =?UTF-8?q?=EA=B2=83=20-=20=ED=94=84=EB=A1=9C=EB=B0=94=EC=9D=B4=EB=8D=94?= =?UTF-8?q?=EB=8A=94=20=EB=B0=A9=EC=9D=84=20=ED=95=98=EB=82=98=EB=A7=8C=20?= =?UTF-8?q?=EB=93=A4=EA=B3=A0=20=EC=9E=88=EC=96=B4=EC=84=9C=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=EA=B0=80=20=EC=8B=A4=ED=8C=A8=ED=95=98=EB=A9=B4=20?= =?UTF-8?q?=EC=95=9E=20=EB=B0=A9=EC=9D=B4=20=EB=82=A8=EB=8A=94=EB=8D=B0=20?= =?UTF-8?q?=EB=B2=88=ED=98=B8=20=ED=99=95=EC=9D=B8=EC=9D=B4=20=EB=A1=9C?= =?UTF-8?q?=EB=94=A9=20=EC=A4=91=EC=97=90=EB=A7=8C=20=EA=B1=B8=EB=A0=A4=20?= =?UTF-8?q?=EC=9E=88=EC=97=88=EB=8B=A4,=20=EC=A0=9C=EB=AA=A9=EA=B3=BC=20?= =?UTF-8?q?=EB=A9=A4=EB=B2=84=EB=8A=94=20=EC=95=9E=20=EB=B0=A9=EC=9D=B8?= =?UTF-8?q?=EB=8D=B0=20=EB=82=98=EA=B0=80=EA=B8=B0=EC=99=80=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=EB=8A=94=20=EC=9D=B4=20=ED=99=94=EB=A9=B4=EC=9D=98=20?= =?UTF-8?q?=EB=B0=A9=20=EB=B2=88=ED=98=B8=EB=A1=9C=20=EB=82=98=EA=B0=80?= =?UTF-8?q?=EC=84=9C=20=EB=B3=B4=EA=B3=A0=20=EC=9E=88=EC=A7=80=EB=8F=84=20?= =?UTF-8?q?=EC=95=8A=EC=9D=80=20=EB=B0=A9=EC=97=90=EC=84=9C=20=EB=82=98?= =?UTF-8?q?=EA=B0=80=EA=B2=8C=20=EB=90=9C=EB=8B=A4,=20=EB=B2=88=ED=98=B8?= =?UTF-8?q?=EA=B0=80=20=EB=8B=A4=EB=A5=B4=EB=A9=B4=20=EC=97=86=EB=8A=94=20?= =?UTF-8?q?=EA=B2=83=EC=9C=BC=EB=A1=9C=20=EB=B3=B4=EA=B3=A0=20=EB=B6=88?= =?UTF-8?q?=EB=9F=AC=EC=98=A4=EC=A7=80=20=EB=AA=BB=ED=96=88=EB=8B=A4?= =?UTF-8?q?=EA=B3=A0=20=EC=95=8C=EB=A6=B0=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../StudyRoom/StudyRoomDetailScreen.dart | 17 +++++++++---- .../study_room_detail_screen_test.dart | 24 ++++++++++++++++++- 2 files changed, 36 insertions(+), 5 deletions(-) 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/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()); From 6301263b335d4cd69b1e70dbda5c504213c96706 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Fri, 25 Sep 2026 15:24:02 +0900 Subject: [PATCH 3/4] =?UTF-8?q?[Fix]=20=EB=B3=B5=EC=8A=B5=20=EC=84=B8?= =?UTF-8?q?=ED=8A=B8=EB=A5=BC=20=EB=B6=88=EB=9F=AC=EC=98=A4=EB=8A=94=20?= =?UTF-8?q?=EC=A4=91=EC=97=90=20=EB=82=98=EA=B0=80=EB=A9=B4=20=EB=8B=A4?= =?UTF-8?q?=EB=A5=B8=20=EC=84=B8=ED=8A=B8=EC=9D=98=20=EB=AC=B8=EC=A0=9C?= =?UTF-8?q?=EA=B0=80=20=EC=84=9E=EC=9D=B4=EB=8D=98=20=EA=B2=83=20-=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=EB=B6=80=ED=84=B0=20=EB=84=98=EA=B8=B0?= =?UTF-8?q?=EA=B3=A0=20=EB=92=A4=EC=97=90=EC=84=9C=20=EB=B6=88=EB=9F=AC?= =?UTF-8?q?=EC=98=A4=EB=8A=94=EB=8D=B0=20=EB=91=90=20await=20=EC=82=AC?= =?UTF-8?q?=EC=9D=B4=EC=97=90=20=ED=99=94=EB=A9=B4=EC=9D=B4=20=EC=82=B4?= =?UTF-8?q?=EC=95=84=20=EC=9E=88=EB=8A=94=EC=A7=80=20=EB=B3=B4=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EC=95=98=EB=8B=A4,=20=EC=84=B8=ED=8A=B8=EB=A5=BC=20?= =?UTF-8?q?=EB=B0=9B=EB=8A=94=20=EB=8F=99=EC=95=88=20=EB=82=98=EA=B0=80?= =?UTF-8?q?=EC=84=9C=20=EB=8B=A4=EB=A5=B8=20=EC=84=B8=ED=8A=B8=EB=A5=BC=20?= =?UTF-8?q?=EC=97=B4=EB=A9=B4=20=EB=8A=A6=EA=B2=8C=20=EB=81=9D=EB=82=9C=20?= =?UTF-8?q?=EC=AA=BD=EC=9D=B4=20=EA=B3=B5=EC=9A=A9=20=EC=83=81=ED=83=9C?= =?UTF-8?q?=EB=A5=BC=20=EB=8D=AE=EC=96=B4=20=ED=99=94=EB=A9=B4=EC=97=90=20?= =?UTF-8?q?=EB=96=A0=20=EC=9E=88=EB=8A=94=20=EC=84=B8=ED=8A=B8=EC=97=90=20?= =?UTF-8?q?=EB=8B=A4=EB=A5=B8=20=EC=84=B8=ED=8A=B8=EC=9D=98=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=EA=B0=80=20=EB=93=A4=EC=96=B4=EA=B0=80=EA=B3=A0=20?= =?UTF-8?q?=EB=B3=B5=EC=8A=B5=20=ED=9A=9F=EC=88=98=EB=8F=84=20=EC=97=89?= =?UTF-8?q?=EB=9A=B1=ED=95=9C=20=EC=84=B8=ED=8A=B8=EC=97=90=20=EC=98=AC?= =?UTF-8?q?=EB=9D=BC=EA=B0=84=EB=8B=A4,=20=ED=99=94=EB=A9=B4=EC=9D=B4=20?= =?UTF-8?q?=EC=82=AC=EB=9D=BC=EC=A1=8C=EC=9C=BC=EB=A9=B4=20=EC=9D=B4?= =?UTF-8?q?=EC=96=B4=EA=B0=80=EC=A7=80=20=EC=95=8A=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PracticeNote/PracticeDetailLoader.dart | 4 + .../practice_detail_loader_test.dart | 115 ++++++++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 test/screen/practice_note/practice_detail_loader_test.dart 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/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: '죽은 로더가 화면에 떠 있는 세트의 문제 목록을 덮으면 복습 횟수도 엉뚱한 세트에 올라간다', + ); + }); +} From 4be46f800ab161b1ca73c703868cbbb20cf10b77 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Fri, 25 Sep 2026 15:24:02 +0900 Subject: [PATCH 4/4] =?UTF-8?q?[Fix]=20=EB=B3=B5=EC=8A=B5=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C=20=EB=B2=84=ED=8A=BC=EC=9D=B4=20=EA=B0=99=EC=9D=80=20?= =?UTF-8?q?=ED=94=84=EB=A0=88=EC=9E=84=EC=97=90=20=EB=91=90=20=EB=B2=88=20?= =?UTF-8?q?=EB=88=8C=EB=A6=AC=EB=A9=B4=20=EB=91=90=20=EB=B2=88=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=EB=90=98=EB=8D=98=20=EA=B2=83=20-=20=EB=B2=84?= =?UTF-8?q?=ED=8A=BC=EC=9D=B4=20=EC=9E=A0=EA=B8=B0=EB=8A=94=20=EA=B2=83?= =?UTF-8?q?=EC=9D=80=20=ED=99=94=EB=A9=B4=EC=9D=84=20=EB=8B=A4=EC=8B=9C=20?= =?UTF-8?q?=EA=B7=B8=EB=A6=B0=20=EB=92=A4=EB=9D=BC=20=EA=B7=B8=20=EC=A0=84?= =?UTF-8?q?=EC=97=90=20=EB=93=A4=EC=96=B4=EC=98=A8=20=EB=91=90=20=EB=B2=88?= =?UTF-8?q?=EC=A7=B8=20=ED=83=AD=EC=9D=80=20=EA=B7=B8=EB=8C=80=EB=A1=9C=20?= =?UTF-8?q?=EC=BD=9C=EB=B0=B1=EC=9D=84=20=ED=83=94=EB=8B=A4,=20=EB=B3=B5?= =?UTF-8?q?=EC=8A=B5=20=ED=9A=9F=EC=88=98=EC=99=80=20=EA=B2=BD=ED=97=98?= =?UTF-8?q?=EC=B9=98=EA=B0=80=20=EB=91=90=20=EB=B2=88=20=EC=98=A4=EB=A5=B4?= =?UTF-8?q?=EB=8A=94=20=EC=9E=90=EB=A6=AC=EB=9D=BC=20=EB=88=8C=EB=A6=B0=20?= =?UTF-8?q?=EC=88=9C=EA=B0=84=EC=97=90=EB=8F=84=20=ED=99=95=EC=9D=B8?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PracticeNote/PracticeCompletionScreen.dart | 4 ++++ .../practice_completion_screen_test.dart | 17 +++++++++++++++++ 2 files changed, 21 insertions(+) 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/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 {});