import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:jj_mkvmaker/app/app_controller.dart'; import 'package:jj_mkvmaker/app/settings.dart'; import 'package:jj_mkvmaker/core/file_ops.dart' show uncRoot; 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/platform_services.dart'; import 'package:jj_mkvmaker/ui/explorer_page.dart'; import 'package:path/path.dart' as p; import 'explorer_test.dart' show act, settle, calm; /// 23: 앞의 복사가 끝나지 않아도 다음 복사를 함께 시작한다 (막지 않음, 진행 막대가 둘) void main() { testWidgets('복사 중에 다른 파일 복사 → 둘 다 진행 · 둘 다 끝남', (tester) async { final tmp = Directory.systemTemp.createTempSync('jj_parallel_'); addTearDown(() => tmp.deleteSync(recursive: true)); final left = p.join(tmp.path, 'left'), right = p.join(tmp.path, 'right'); File(p.join(left, 'big.bin')) ..createSync(recursive: true) ..writeAsBytesSync(List.filled(3 << 20, 7)); File(p.join(left, 'doc.txt')).writeAsStringSync('hello'); Directory(right).createSync(); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); c.settings ..explorerPaths = [left, right] ..copyBandwidthKBps = 512; // 큰 파일이 6초쯤 걸리게 tester.view.physicalSize = const Size(1600, 900); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c)))); await settle(tester, () => find.text('doc.txt').evaluate().isNotEmpty); Future copy(String name) async { await act(tester, () => tester.tap(find.text(name).first)); await settle(tester, () => false, rounds: 15); await act(tester, () => tester.tap(find.text('복사').first)); await tester.pump(const Duration(milliseconds: 300)); await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '복사'))); await tester.pump(const Duration(milliseconds: 300)); } await copy('big.bin'); await settle(tester, () => find.textContaining('복사하는 중').evaluate().isNotEmpty, rounds: 30); await copy('doc.txt'); expect(find.text('앞의 복사 · 이동이 끝난 뒤에 하세요.'), findsNothing); // 둘이 함께 돈다 (진행 막대 두 줄) expect(find.textContaining('복사하는 중'), findsNWidgets(2)); await settle(tester, () => File(p.join(right, 'doc.txt')).existsSync(), rounds: 100); await settle(tester, () => File(p.join(right, 'big.bin')).existsSync() && find.byType(LinearProgressIndicator).evaluate().isEmpty, rounds: 400); expect(File(p.join(right, 'big.bin')).lengthSync(), 3 << 20); expect(File(p.join(right, 'doc.txt')).readAsStringSync(), 'hello'); }); testWidgets('191: 좁은 Rsync 화면 (폴드 접은 화면) 은 창 하나씩 - [왼쪽 | 오른쪽] 으로 바꿔 보기, 넓으면 두 창', (tester) async { final tmp = Directory.systemTemp.createTempSync('jj_rsync_narrow_'); addTearDown(() => tmp.deleteSync(recursive: true)); final l = p.join(tmp.path, 'L'), r = p.join(tmp.path, 'R'); Directory(p.join(l, 'sub')).createSync(recursive: true); Directory(p.join(r, 'rsub')).createSync(recursive: true); ExplorerPage.debugVolumes = () => [(tmp.path, 'T:')]; addTearDown(() => ExplorerPage.debugVolumes = null); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); c.settings ..explorerOrientation = 'side' ..rsyncPaths = [l, r]; tester.view.physicalSize = const Size(400, 860); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c, rsync: true)))); await settle(tester, () => find.text('sub').evaluate().isNotEmpty); // 왼쪽 창만 (오른쪽 폴더는 안 보임) · 방향 버튼은 보임 expect(find.text('왼쪽 · L'), findsOneWidget); expect(find.text('오른쪽 · R'), findsOneWidget); expect(find.text('rsub'), findsNothing); expect(find.text('좌 → 우'), findsOneWidget); // 창 하나가 화면 폭을 다 쓴다 (예전에는 약 140px) expect(tester.getSize(find.ancestor(of: find.text('sub'), matching: find.byType(ListView)).first).width, greaterThan(300)); await tester.tap(find.text('오른쪽 · R')); await settle(tester, () => find.text('rsub').evaluate().isNotEmpty); expect(find.text('rsub'), findsOneWidget); expect(find.text('sub'), findsNothing); // 넓으면 두 창 그대로 tester.view.physicalSize = const Size(1280, 800); await settle(tester, () => find.text('sub').evaluate().isNotEmpty); expect(find.text('sub'), findsOneWidget); expect(find.text('rsub'), findsOneWidget); expect(find.text('왼쪽 · L'), findsNothing); expect(tester.takeException(), isNull); }); testWidgets('208: 폰 가로 (낮은 화면) Rsync 화면에서도 방향 실행 버튼 · 모니터링은 늘 보인다 ([더 보기] 로 가는 것은 뒤쪽)', (tester) async { final tmp = Directory.systemTemp.createTempSync('jj_rsync_low_'); addTearDown(() => tmp.deleteSync(recursive: true)); final l = p.join(tmp.path, 'L'), r = p.join(tmp.path, 'R'); Directory(l).createSync(recursive: true); Directory(r).createSync(recursive: true); ExplorerPage.debugVolumes = () => [(tmp.path, 'T:')]; addTearDown(() => ExplorerPage.debugVolumes = null); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); c.settings ..explorerOrientation = 'side' ..rsyncPaths = [l, r]; tester.view.physicalSize = const Size(860, 400); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c, rsync: true)))); await settle(tester, () => find.text('좌 → 우').evaluate().isNotEmpty); for (final t in ['좌 → 우', '좌 ← 우', '좌 ⇄ 우', '모니터링']) { expect(find.text(t), findsOneWidget, reason: t); } expect(tester.takeException(), isNull); }); testWidgets('221: 폰 세로 Rsync (한 창) 의 저장소 탭은 글이 다 보인다 (SD 카드가 잘리지 않음)', (tester) async { final tmp = Directory.systemTemp.createTempSync('jj_rsync_tabs_'); addTearDown(() => tmp.deleteSync(recursive: true)); final inner = p.join(tmp.path, 'inner'), sd = p.join(tmp.path, 'sd'); Directory(p.join(inner, 'L')).createSync(recursive: true); Directory(p.join(sd, 'R')).createSync(recursive: true); ExplorerPage.debugVolumes = () => [(inner, '내장 저장소'), (sd, 'SD 카드')]; addTearDown(() => ExplorerPage.debugVolumes = null); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); c.settings ..explorerOrientation = 'side' ..rsyncPaths = [p.join(inner, 'L'), p.join(sd, 'R')]; tester.view.physicalSize = const Size(412, 880); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c, rsync: true)))); await settle(tester, () => find.text('SD 카드').evaluate().isNotEmpty); // 탭 글은 눌리지 않는다 (글 폭 그대로) - 넘치면 옆으로 밀어서 다 보인다 (202 와 같은 방식) final plain = TextPainter(text: const TextSpan(text: 'SD 카드'), textDirection: TextDirection.ltr)..layout(); expect(tester.getSize(find.text('SD 카드').first).width, greaterThanOrEqualTo(plain.width * 0.9), reason: '"(" 처럼 눌리지 않음'); final tabs = find.ancestor(of: find.text('SD 카드').first, matching: find.byType(ListView)).first; await tester.drag(tabs, const Offset(-400, 0)); await settle(tester, () => false, rounds: 10); expect(tester.getRect(find.text('SD 카드').first).right, lessThanOrEqualTo(tester.getRect(tabs).right + 1), reason: '밀면 끝까지 보임'); expect(find.byTooltip('경로 입력 · WebDAV'), findsOneWidget, reason: '끝 버튼은 [⋮] 하나'); expect(tester.takeException(), isNull); }); testWidgets('25: Rsync 화면 폴더도 길게 누르면 메뉴 (고르기 · 열기 · 이름 변경 · 삭제 · 정보)', (tester) async { final tmp = Directory.systemTemp.createTempSync('jj_rsync_menu_'); addTearDown(() => tmp.deleteSync(recursive: true)); final l = p.join(tmp.path, 'L'), r = p.join(tmp.path, 'R'); Directory(p.join(l, 'sub')).createSync(recursive: true); Directory(p.join(r, 'rsub')).createSync(recursive: true); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); c.settings ..explorerLayout = 'single' ..rsyncPaths = [l, r]; tester.view.physicalSize = const Size(1600, 900); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c, rsync: true)))); await settle(tester, () => find.text('sub').evaluate().isNotEmpty); await tester.longPress(find.text('sub')); await calm(tester); for (final t in ['rsync 에 고르기', '열기', '다른 창에서 열기', '이름 변경', '삭제', '정보']) { expect(find.text(t), findsOneWidget, reason: t); } expect(find.text('다른 앱으로 열기'), findsNothing); // 파일 메뉴는 없음 await act(tester, () => tester.tap(find.text('rsync 에 고르기'))); await settle(tester, () => false, rounds: 5); expect(find.byTooltip('고르기 취소'), findsOneWidget); }); test('37: 네트워크 공유 경로의 맨 위', () { expect(uncRoot(r'\\NAS\영상\드라마\1화.mkv'), r'\\NAS\영상'); expect(uncRoot(r'\\NAS\영상'), r'\\NAS\영상'); }); testWidgets('37: 경로를 직접 넣어 가기 · 없는 경로는 알림', (tester) async { final tmp = Directory.systemTemp.createTempSync('jj_enter_path_'); addTearDown(() => tmp.deleteSync(recursive: true)); final l = p.join(tmp.path, 'L'), deep = p.join(l, 'a', 'b'); File(p.join(deep, 'deep.txt')) ..createSync(recursive: true) ..writeAsStringSync('d'); File(p.join(l, 'top.txt')).writeAsStringSync('t'); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); c.settings ..explorerLayout = 'single' ..explorerPaths = [l, l]; tester.view.physicalSize = const Size(1600, 900); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c)))); await settle(tester, () => find.text('top.txt').evaluate().isNotEmpty); await tester.tap(find.byTooltip('경로 입력').first); await calm(tester); await tester.enterText(find.byType(TextField).last, p.join(tmp.path, 'none')); await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '확인'))); await settle(tester, () => find.textContaining('폴더가 없거나 열 수 없습니다').evaluate().isNotEmpty, rounds: 20); expect(find.textContaining('폴더가 없거나 열 수 없습니다'), findsOneWidget); // 146: 창은 열린 채 - 그 칸에서 고쳐 넣는다 expect(find.byType(AlertDialog), findsOneWidget); await tester.enterText(find.byType(TextField).last, deep); await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '확인'))); await settle(tester, () => find.text('deep.txt').evaluate().isNotEmpty); expect(find.text('deep.txt'), findsOneWidget); }); testWidgets('122: 앱 밖에서 만든 폴더도 경로 입력으로 가면 새로 읽어 펼치고 화면에 보인다', (tester) async { final tmp = Directory.systemTemp.createTempSync('jj_enter_fresh_'); addTearDown(() => tmp.deleteSync(recursive: true)); final dl = p.join(tmp.path, 'Download'); // 앞쪽에 파일이 많아 새 폴더가 화면 아래로 밀리는 Download for (var i = 0; i < 60; i++) { File(p.join(dl, 'a$i.txt')) ..createSync(recursive: true) ..writeAsStringSync('$i'); } final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); c.settings ..explorerLayout = 'single' ..explorerPaths = [dl, dl]; tester.view.physicalSize = const Size(1600, 900); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c)))); await settle(tester, () => find.text('a0.txt').evaluate().isNotEmpty); // 앱이 Download 를 읽은 뒤에 다른 앱이 만든 폴더 (이름 순으로 맨 뒤) final made = p.join(dl, 'zz_jjsup'); File(p.join(made, 'pg1.png')) ..createSync(recursive: true) ..writeAsBytesSync([1, 2, 3]); await tester.tap(find.byTooltip('경로 입력').first); await calm(tester); await tester.enterText(find.byType(TextField).last, made); await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '확인'))); await settle(tester, () => find.text('pg1.png').evaluate().isNotEmpty, rounds: 300); expect(find.text('zz_jjsup'), findsOneWidget); expect(find.text('pg1.png').hitTestable(), findsOneWidget, reason: '그 자리로 스크롤되어 보임'); }); testWidgets('38: 좁고 낮은 창 (접은 폴드 · 위아래 두 창) 에서 안내가 있어도 목록이 보인다', (tester) async { final tmp = Directory.systemTemp.createTempSync('jj_notice_'); addTearDown(() => tmp.deleteSync(recursive: true)); final l = p.join(tmp.path, 'L'); for (var i = 0; i < 4; i++) { File(p.join(l, 'part$i.mkv.jjpart')) ..createSync(recursive: true) ..writeAsStringSync('x') ..setLastModifiedSync(DateTime.now().subtract(const Duration(hours: 1))); } File(p.join(l, 'zz_file.txt')).writeAsStringSync('t'); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); c.settings ..explorerLayout = 'dual' ..explorerOrientation = 'stacked' ..explorerPaths = [l, l]; tester.view.physicalSize = const Size(420, 640); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c)))); await settle(tester, () => find.textContaining('만들다 만 파일').evaluate().isNotEmpty); expect(tester.takeException(), isNull); // 넘침 (overflow) 없음 // 목록 (창마다) 이 밀려 사라지지 않고 높이가 남아 있다 final lists = find.byWidgetPredicate((w) => w is ListView && w.scrollDirection == Axis.vertical); expect(lists, findsWidgets); for (final e in lists.evaluate()) { expect(tester.getSize(find.byWidget(e.widget)).height, greaterThan(80)); } }); test('118: 두 창 배치 처음 값은 좌우 · 예전 "화면 모양 따라" 는 한 번 좌우로 (알림) · 고른 위아래는 그대로', () { expect(AppSettings().explorerOrientation, 'side'); expect(AppSettings.fromJson({}).explorerOrientation, 'side'); final old = AppSettings.fromJson({'explorerOrientation': 'auto'}); expect(old.explorerOrientation, 'side'); expect(old.migrated, contains('explorerOrientation')); final stacked = AppSettings.fromJson({'explorerOrientation': 'stacked'}); expect(stacked.explorerOrientation, 'stacked'); expect(stacked.migrated, isNot(contains('explorerOrientation'))); // 옮긴 뒤 사용자가 다시 '화면 모양 따라' 를 고르면 그대로 final again = AppSettings.fromJson((AppSettings()..explorerOrientation = 'auto').toJson()); expect(again.explorerOrientation, 'auto'); expect(again.migrated, isEmpty); }); testWidgets('118: 가운데 버튼 [좌우 ⇆ / 위아래 ⇅] - 한 번 눌러 바로 바뀜 · 한 창이면 두 창 (좌우) 으로', (tester) async { final tmp = Directory.systemTemp.createTempSync('jj_orient_'); addTearDown(() => tmp.deleteSync(recursive: true)); final l = p.join(tmp.path, 'L'); File(p.join(l, 'a.txt')) ..createSync(recursive: true) ..writeAsStringSync('a'); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); c.settings ..explorerLayout = 'single' ..explorerPaths = [l, l]; // 폰 세로처럼 좁고 긴 화면 tester.view.physicalSize = const Size(500, 1000); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c)))); await settle(tester, () => find.text('a.txt').evaluate().isNotEmpty); // 그 버튼을 누른다: 버튼 줄에 있으면 바로, 다 들어가지 않아 [더 보기] 메뉴에 있으면 메뉴에서 (147) Future press(String tooltip, String label) async { if (find.byTooltip(tooltip).evaluate().isNotEmpty) { await act(tester, () => tester.tap(find.byTooltip(tooltip))); } else { await tester.tap(find.text('더 보기')); await calm(tester); expect(find.text(label), findsOneWidget, reason: '[더 보기] 메뉴에 지금 배치가 보임: $label'); await act(tester, () => tester.tap(find.text(label))); } await settle(tester, () => false, rounds: 5); } await press('두 창으로 보기 (좌우)', '두 창으로'); expect((c.settings.explorerLayout, c.settings.explorerOrientation), ('dual', 'side'), reason: '폰 세로에서도 좌우'); expect(find.text('a.txt'), findsNWidgets(2)); final left = tester.getCenter(find.text('a.txt').first), right = tester.getCenter(find.text('a.txt').last); expect(right.dx, greaterThan(left.dx)); await press('창 배치: 좌우 (누르면 위아래)', '좌우 ⇆'); expect(c.settings.explorerOrientation, 'stacked'); final top = tester.getCenter(find.text('a.txt').first), bottom = tester.getCenter(find.text('a.txt').last); expect(bottom.dy, greaterThan(top.dy)); await press('창 배치: 위아래 (누르면 좌우)', '위아래 ⇅'); expect(c.settings.explorerOrientation, 'side'); }); }