import 'dart:convert'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:jj_mkvmaker/app/ai_local.dart'; import 'package:jj_mkvmaker/app/app_controller.dart'; import 'package:jj_mkvmaker/app/model_backup.dart'; import 'package:jj_mkvmaker/app/version_snapshot.dart'; import 'package:jj_mkvmaker/core/app_update.dart'; import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; import 'package:jj_mkvmaker/services/model_store.dart'; import 'package:jj_mkvmaker/services/platform_services.dart'; import 'package:jj_mkvmaker/services/updater.dart'; import 'package:jj_mkvmaker/ui/update_dialog.dart'; import 'package:path/path.dart' as p; Map _release(String tag, {String? digest, String body = '', bool draft = false, String? url}) => { 'tag_name': tag, 'name': 'JJ_MKVMaker $tag', 'body': body, 'draft': draft, 'prerelease': false, 'html_url': 'https://github.com/$updateRepo/releases/tag/$tag', 'assets': [ { 'name': 'JJ_MKVMaker_${tag}_win64.zip', 'size': 1234, 'browser_download_url': url ?? 'https://example.com/x.zip', 'digest': ?digest, }, ], }; class _FakeUpdater implements Updater { @override bool installsInPlace = false; /// 설치할 때 "설치 허용 필요" 로 실패할 횟수 (Android) int needPermission = 0; ReleaseInfo? next; List all = []; /// 받은 파일 (없으면 C:\tmp\new) String? downloaded; final calls = []; @override Future> releases() async => all; @override Future uninstallSelf() async => calls.add('uninstall'); @override Future currentVersion() async => '1.0.1'; @override Future latest() async => next; @override Future canInstall() async => true; @override Future download(ReleaseInfo r, void Function(double) onProgress) async { calls.add('download ${r.version}'); onProgress(1); return downloaded ?? r'C:\tmp\new'; } @override Future scheduleInstall(String dir) async { calls.add('install $dir'); if (needPermission > 0) { needPermission--; throw const InstallPermissionNeeded('허용 필요'); } } @override Future openPage(ReleaseInfo r) async => calls.add('page'); } /// 실제 파일 작업 (크기 재기 · 남은 공간 · 복사) 이 끝날 때까지 Future settleIo(WidgetTester tester, {required bool Function() until}) async { // 위젯 시험 안의 파일 읽기 · 쓰기는 pump 마다 조금씩 나아간다 - 넉넉히 (끝나면 바로 나옴) for (var i = 0; i < 600 && !until(); i++) { await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 100))); await tester.pump(); } await tester.pumpAndSettle(); } void main() { group('업데이트 창', () { late _FakeUpdater up; late AppController c; setUp(() { up = _FakeUpdater(); c = AppController(PlatformServices( mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService(), updater: up)); }); testWidgets('164: [최신 버전 확인] 결과는 알림이 아니라 버튼 옆에 (최신 · 새 버전)', (tester) async { tester.view.physicalSize = const Size(1200, 900); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); await tester.pumpWidget(MaterialApp(home: Scaffold(body: Center(child: UpdateCheckButton(c: c))))); // 확인하는 동안 · 새 버전 창이 떠 있는 동안 도는 표시가 있어 pumpAndSettle 대신 될 때까지 Future until(bool Function() ok) async { for (var i = 0; i < 100 && !ok(); i++) { await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 50))); await tester.pump(const Duration(milliseconds: 50)); } } await tester.tap(find.text('최신 버전 확인')); await until(() => find.textContaining('최신 버전입니다').evaluate().isNotEmpty); expect(find.text('최신 버전입니다 (v1.0.1)'), findsOneWidget); expect(find.byType(SnackBar), findsNothing); up.next = ReleaseInfo(version: '1.0.2', tag: 'v1.0.2', name: '', notes: '', pageUrl: '', zipUrl: 'https://x/a.zip'); await tester.tap(find.text('최신 버전 확인')); await until(() => find.textContaining('새 버전 v1.0.2 이 있습니다').evaluate().isNotEmpty); expect(find.text('새 버전 v1.0.2 이 있습니다'), findsOneWidget); // 새 버전이 있으면 예전처럼 안내 창도 뜬다 expect(find.text('새 버전 v1.0.2'), findsWidgets); await tester.tap(find.text('나중에').last); await until(() => find.byType(CircularProgressIndicator).evaluate().isEmpty); expect(find.byType(CircularProgressIndicator), findsNothing); }); Future open(WidgetTester tester, {bool manual = true}) async { tester.view.physicalSize = const Size(1200, 900); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); await tester.pumpWidget(MaterialApp( home: Scaffold( body: Builder( builder: (ctx) => TextButton( onPressed: () => checkForUpdate(ctx, c, manual: manual), child: const Text('확인'), ), ), ), )); await tester.tap(find.text('확인')); await tester.pumpAndSettle(); } testWidgets('최신이면 알림만', (tester) async { up.next = parseLatestRelease(_release('v1.0.1')); await open(tester); expect(find.text('최신 버전입니다 (v1.0.1)'), findsOneWidget); }); testWidgets('새 버전 → 업데이트 → 받기 → 지금 설치', (tester) async { up.next = parseLatestRelease(_release('v1.1.0', body: '- 재생목록 전체 받기')); await open(tester); expect(find.text('새 버전 v1.1.0'), findsOneWidget); expect(find.text('지금 쓰는 버전: v1.0.1 → 새 버전: v1.1.0'), findsOneWidget); expect(find.text('- 재생목록 전체 받기'), findsOneWidget); await tester.tap(find.text('업데이트')); await tester.pumpAndSettle(); expect(find.text('설치 준비 완료'), findsOneWidget); await tester.tap(find.text('지금 설치')); await tester.pumpAndSettle(); expect(up.calls, ['download 1.1.0', r'install C:\tmp\new']); }); testWidgets('Android: 설치 허용이 꺼져 있으면 [설치 계속] → 받은 파일로 다시 설치 (다시 받지 않음)', (tester) async { up ..installsInPlace = true ..needPermission = 1 ..next = parseLatestRelease(_release('v1.1.0')); await open(tester); await tester.tap(find.text('업데이트')); await tester.pumpAndSettle(); await tester.tap(find.text('지금 설치')); await tester.pumpAndSettle(); expect(find.text('설치 허용 필요'), findsOneWidget); await tester.tap(find.text('설치 계속')); await tester.pumpAndSettle(); expect(find.text('설치 허용 필요'), findsNothing); expect(up.calls, ['download 1.1.0', r'install C:\tmp\new', r'install C:\tmp\new']); }); testWidgets('이 버전 건너뛰기 → 자동 확인에서 다시 묻지 않음', (tester) async { up.next = parseLatestRelease(_release('v1.1.0')); await open(tester); await tester.tap(find.text('이 버전 건너뛰기')); await tester.pumpAndSettle(); expect(c.settings.run.skippedVersion, '1.1.0'); c.settings.run.lastUpdateCheck = ''; await open(tester, manual: false); expect(find.text('새 버전 v1.1.0'), findsNothing); }); group('버전 고르기 · 되돌리기', () { late Directory data; setUp(() { data = Directory.systemTemp.createTempSync('jj_ver_'); File(p.join(data.path, 'settings.json')) .writeAsStringSync(jsonEncode({'uiLanguage': 'ko', 'openSubtitlesKey': 'SECRET-KEY', 'lastRunVersion': '1.0.1'})); File(p.join(data.path, 'videos.json')).writeAsStringSync('[]'); VersionSnapshot.instance = VersionSnapshot(data.path, sharedDir: p.join(data.path, 'shared', '설정 보관')); // P0: 받은 모델 폴더도 시험 폴더 안으로 (시험 프로그램 옆 models 를 건드리지 않게) c = AppController(PlatformServices( mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService(), updater: up, models: ModelStore(p.join(data.path, 'models')))); AiStore.instance = AiStore(data.path); ModelBackup.sharedRoot = p.join(data.path, 'shared'); up.all = [ parseRelease(_release('v1.0.2'), allowPrerelease: true)!, parseRelease(_release('v1.0.1'), allowPrerelease: true)!, parseRelease(_release('v1.0.0'), allowPrerelease: true)!, ]; }); tearDown(() { VersionSnapshot.instance = null; AiStore.instance = null; ModelBackup.sharedRoot = null; data.deleteSync(recursive: true); }); Future openPicker(WidgetTester tester, {Size size = const Size(1200, 900)}) async { tester.view.physicalSize = size; tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); await tester.pumpWidget(MaterialApp( home: Scaffold( body: Builder(builder: (ctx) => TextButton(onPressed: () => chooseVersion(ctx, c), child: const Text('고르기'))), ), )); await tester.tap(find.text('고르기')); await tester.pumpAndSettle(); } testWidgets('168: 좁은 화면 (폰) 에서는 목록 위 · 바뀐 내용 아래 (넓으면 나란히)', (tester) async { await openPicker(tester, size: const Size(400, 800)); final list = tester.getTopLeft(find.text('v1.0.0').first); final notes = tester.getTopLeft(find.text('바뀐 내용')); expect(notes.dy, greaterThan(list.dy), reason: '바뀐 내용은 목록 아래'); expect(tester.getSize(find.text('바뀐 내용')).width, lessThan(400)); await tester.tap(find.text('닫기')); await tester.pumpAndSettle(); await openPicker(tester); expect(tester.getTopLeft(find.text('바뀐 내용')).dx, greaterThan(tester.getTopLeft(find.text('v1.0.0').first).dx + 200), reason: '넓으면 나란히 (오른쪽)'); }); testWidgets('올려 둔 모든 버전 · 지금 · 최신 표시 → 예전 버전으로 되돌리기 (설치 전 설정 보관, 비밀 값 빼고)', (tester) async { await openPicker(tester); expect(find.text('버전 고르기'), findsOneWidget); for (final v in ['v1.0.2', 'v1.0.1', 'v1.0.0']) { expect(find.text(v), findsWidgets); } expect(find.text('지금'), findsOneWidget); expect(find.text('최신'), findsOneWidget); // 169: 처음엔 최신 버전이 골라져 있음 (이 창을 여는 사람은 대개 업데이트하려는 것) expect(find.text('이 버전으로 업데이트'), findsOneWidget); await tester.tap(find.text('v1.0.1').first); await tester.pumpAndSettle(); expect(find.text('다시 설치'), findsOneWidget); await tester.tap(find.text('v1.0.0').first); await tester.pumpAndSettle(); expect(find.text('이 버전으로 되돌리기'), findsOneWidget); expect(find.textContaining('예전 버전으로 되돌립니다'), findsOneWidget); await tester.tap(find.text('이 버전으로 되돌리기')); await tester.pumpAndSettle(); expect(find.textContaining('예전 버전 (v1.0.0) 으로 되돌린 뒤'), findsOneWidget); await tester.tap(find.text('지금 설치')); for (var i = 0; i < 50 && up.calls.length < 2; i++) { await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 100))); await tester.pump(); } expect(up.calls, ['download 1.0.0', r'install C:\tmp\new']); // 지금 버전 (1.0.1) 의 설정을 보관 (비밀 값은 빼고) final kept = File(p.join(data.path, 'version_snapshots', '1.0.1', 'settings.json')); expect(kept.existsSync(), isTrue); expect(kept.readAsStringSync(), isNot(contains('SECRET-KEY'))); expect(File(p.join(data.path, 'version_snapshots', '1.0.1', 'videos.json')).existsSync(), isTrue); }); testWidgets('Android: 예전 버전은 APK 와 설정을 Download 에 저장하고 앱 지우기', (tester) async { up.installsInPlace = true; final apk = File(p.join(data.path, 'dl.apk'))..writeAsStringSync('apk'); up.downloaded = apk.path; await openPicker(tester); await tester.tap(find.text('v1.0.0').first); await tester.pumpAndSettle(); await tester.tap(find.text('이 버전으로 되돌리기')); await settleIo(tester, until: () => find.text('저장하고 계속').evaluate().isNotEmpty); expect(find.textContaining('Android 는 버전이 낮은 앱을'), findsOneWidget); // P0: 남는 것 / 사라지는 것을 사실대로 (받은 모델이 없으면 모델 줄은 없음) expect(find.text('남는 것'), findsOneWidget); expect(find.text('사라지는 것'), findsOneWidget); expect(find.textContaining('비밀번호 · API 키'), findsOneWidget); expect(find.textContaining('브라우저의 로그인'), findsOneWidget); expect(find.textContaining('다시 받아야 합니다'), findsNothing); await tester.tap(find.text('저장하고 계속')); await tester.pumpAndSettle(); // 사라지는 것이 있으니 한 번 더 expect(find.text('정말 되돌릴까요?'), findsOneWidget); await tester.tap(find.text('되돌리기 계속')); // 받기 · 파일 복사 (실제 파일 작업) 가 끝날 때까지 for (var i = 0; i < 50 && find.text('저장했습니다').evaluate().isEmpty; i++) { await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 100))); await tester.pump(); } expect(find.text('저장했습니다'), findsOneWidget); expect(File(p.join(data.path, 'shared', 'JJ_MKVMaker_v1.0.0_win64.zip')).existsSync(), isTrue); // 설치 파일 사본 expect(File(p.join(data.path, 'shared', '설정 보관', '1.0.1', 'settings.json')).existsSync(), isTrue); await tester.tap(find.text('앱 지우기')); await tester.pumpAndSettle(); expect(up.calls, ['download 1.0.0', 'uninstall']); }); testWidgets('P0 Android: 받은 AI 모델을 복사해 두기 (기본: 남은 공간이 2배 이상이면 켬) · 끄면 "다시 받아야" · [저장하고 계속] 때만 설정에 기억', (tester) async { up.installsInPlace = true; up.downloaded = (File(p.join(data.path, 'dl.apk'))..writeAsStringSync('apk')).path; File(p.join(data.path, 'ai', 'models', 'esrgan.pth')) ..createSync(recursive: true) ..writeAsBytesSync(List.filled(2 << 20, 1)); // 2MB File(p.join(data.path, 'models', 'whisper', 'ggml-base.bin')) ..createSync(recursive: true) ..writeAsBytesSync(List.filled(1 << 20, 2)); // 1MB await openPicker(tester); await tester.tap(find.text('v1.0.0').first); await tester.pumpAndSettle(); await tester.tap(find.text('이 버전으로 되돌리기')); await settleIo(tester, until: () => find.text('저장하고 계속').evaluate().isNotEmpty); // 시험 PC 는 공간이 넉넉하니 처음 값은 켬 → 남는 것에 모델 (공용 폴더가 아직 없어도 상위 폴더로 잼) expect(find.textContaining('남은 공간'), findsOneWidget); expect(find.textContaining('받은 AI 모델 (3MB) - Download/JJ_MKVMaker/AI 모델 보관'), findsOneWidget); // 예전 판 (v1.0.0) 은 옮겨 둔 모델 · 설정을 되살리지 못한다고 사실대로 expect(find.textContaining('v1.0.0 은 복사해 둔 모델을 되살리지 못합니다'), findsOneWidget); expect(find.textContaining('v1.0.0 은 저장해 둔 설정을 되살리지 못해'), findsOneWidget); // 끄면 사라지는 것으로 · 설정에 기억 await tester.tap(find.byType(Checkbox)); await tester.pumpAndSettle(); expect(find.textContaining('다시 받아야 합니다 (약 3MB)'), findsOneWidget); // 체크를 바꿔도 [취소] 하면 설정은 그대로 (자동) await tester.tap(find.text('취소')); await tester.pumpAndSettle(); expect(c.settings.run.rollbackKeepModels, ''); await openPicker(tester); await tester.tap(find.text('v1.0.0').first); await tester.pumpAndSettle(); await tester.tap(find.text('이 버전으로 되돌리기')); await settleIo(tester, until: () => find.text('저장하고 계속').evaluate().isNotEmpty); // 끄고 다시 켠 뒤 [저장하고 계속] 을 누를 때 설정에 → 공용 폴더에 모델 사본과 목록 await tester.tap(find.byType(Checkbox)); await tester.pumpAndSettle(); await tester.tap(find.byType(Checkbox)); await tester.pumpAndSettle(); expect(c.settings.run.rollbackKeepModels, ''); await tester.tap(find.text('저장하고 계속')); await tester.pumpAndSettle(); expect(c.settings.run.rollbackKeepModels, 'on'); await tester.tap(find.text('되돌리기 계속')); await settleIo(tester, until: () => find.text('저장했습니다').evaluate().isNotEmpty); expect(find.text('저장했습니다'), findsOneWidget); final kept = ModelBackup.shared!; expect(kept.exists, isTrue); expect(kept.savedSize, 3 << 20); expect(File(p.join(kept.dir, 'ai', 'models', 'esrgan.pth')).lengthSync(), 2 << 20); expect(File(p.join(kept.dir, 'models', 'whisper', 'ggml-base.bin')).existsSync(), isTrue); }); }); }); }