import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:jj_mkvmaker/app/transfer_job.dart'; import 'package:jj_mkvmaker/core/sync_preview.dart'; import 'package:jj_mkvmaker/app/settings.dart'; import 'package:jj_mkvmaker/core/sync_tools.dart' show CopyMethod; import 'package:jj_mkvmaker/core/webdav.dart'; import 'package:path/path.dart' as p; import 'support/dav_server.dart'; /// 214: 앱이 직접 하는 "안의 것" 이동 (WebDAV 가 낄 때 · 현재 방식) 은 대상에 맞춘 원본 파일만 지운다. /// 예전에는 맞춘 뒤 원본 폴더를 다시 읽어 모든 파일을 지웠다 (건너뛴 것 · 도중에 생긴 것 · 링크 너머까지). void main() { late Directory tmp; late String src, dst; setUp(() { tmp = Directory.systemTemp.createTempSync('jj_move_settled_'); src = p.join(tmp.path, 'src'); dst = p.join(tmp.path, 'dst'); File(p.join(src, 'a.txt')) ..createSync(recursive: true) ..writeAsStringSync('a'); Directory(dst).createSync(); }); tearDown(() { if (tmp.existsSync()) tmp.deleteSync(recursive: true); }); TransferJob moveJob({String options = ''}) => TransferJob(sources: [src], dest: dst, move: true, contents: true, options: options); test('214 a: -u 로 건너뛴 파일 (대상이 더 새것) 은 원본에 남는다 (rsync 처럼)', () async { final old = File(p.join(src, 'keep.txt'))..writeAsStringSync('old source'); old.setLastModifiedSync(DateTime(2020)); File(p.join(dst, 'keep.txt')).writeAsStringSync('newer target'); final job = moveJob(options: '-u'); await job.run(); expect(job.error, isNull); expect(File(p.join(dst, 'a.txt')).readAsStringSync(), 'a'); expect(File(p.join(src, 'a.txt')).existsSync(), isFalse, reason: '맞춘 것은 지움'); expect(old.existsSync(), isTrue, reason: '건너뛴 것은 남김 (예전에는 지웠다)'); expect(File(p.join(dst, 'keep.txt')).readAsStringSync(), 'newer target'); // 216 · 212: 원본에 남은 것을 다시 세어 끝 알림에 expect(job.leftInSource, 1); expect(job.doneSummary(), contains('이동 끝: 1개 · src → dst')); expect(job.doneSummary(), contains('1개는 원본에 남음 (건너뜀)')); }); test('214 c: 맞추는 동안 원본에 새로 생긴 파일은 지우지 않는다', () async { // 천천히 보내는 큰 파일 (맞추는 동안 새 파일을 만든다) File(p.join(src, 'big.bin')).writeAsBytesSync(List.filled(300 * 1024, 7)); final job = TransferJob(sources: [src], dest: dst, move: true, contents: true, bandwidthKBps: 400); final running = job.run(); await Future.delayed(const Duration(milliseconds: 250)); File(p.join(src, 'arrived.txt')).writeAsStringSync('new'); await running; expect(job.error, isNull); expect(File(p.join(dst, 'big.bin')).lengthSync(), 300 * 1024); expect(File(p.join(src, 'big.bin')).existsSync(), isFalse); expect(File(p.join(src, 'arrived.txt')).existsSync(), isTrue, reason: '복사하지 않은 것은 남김'); }); test('214 d: 원본 안의 링크 (junction) 는 따라가지 않는다 - 링크가 가리키는 바깥 폴더의 파일은 그대로', () async { final outside = Directory(p.join(tmp.path, 'outside'))..createSync(); final precious = File(p.join(outside.path, 'precious.txt'))..writeAsStringSync('keep me'); try { Link(p.join(src, 'jn')).createSync(outside.path); } on FileSystemException { markTestSkipped('이 환경에서는 링크를 만들 수 없음'); return; } final job = moveJob(); await job.run(); expect(job.error, isNull); expect(precious.existsSync(), isTrue); expect(precious.readAsStringSync(), 'keep me'); expect(File(p.join(src, 'a.txt')).existsSync(), isFalse); }); test('215: rsync 가 원본을 못 지우면 쉬운 말 + 오류 줄 (끝쪽) 을 보인다 - 전송한 파일 이름만 보이지 않게', () { final out = [for (var i = 0; i < 15; i++) 'movie$i.mkv']; final err = [ 'sender failed to remove movie3.mkv: Permission denied (13)', 'sender failed to remove movie4.mkv: Permission denied (13)', 'rsync error: some files/attrs were not transferred (see previous errors) (code 23) at main.c(1338) [sender=3.4.1]', ]; final text = rsyncFailureText(23, err, [...out, ...err]); expect(text, startsWith('원본 파일을 지우지 못했습니다')); expect(text, contains('rsync 종료 코드 23')); expect(text, contains('Permission denied')); expect(text, contains('(code 23)')); expect(text, isNot(contains('movie10.mkv')), reason: '전송한 파일 이름 줄이 아니라 오류 줄'); // 원본 지우기와 상관없는 실패는 그 말을 붙이지 않는다 expect(rsyncFailureText(12, ['rsync error: error in rsync protocol data stream (code 12)'], const []), isNot(contains('원본 파일을 지우지'))); }); test('217: robocopy 이동 - 대상에 같은 파일이 있어도 원본에서 지운다 (예전에는 남기고 "옮겼습니다")', () async { if (!Platform.isWindows) return; // robocopy 가 "같음" 으로 보는 파일 (탐색기 · Copy-Item 복사처럼 속성까지 같은 것) final r = Process.runSync('powershell', ['-NoProfile', '-Command', "Copy-Item -LiteralPath '${p.join(src, 'a.txt')}' -Destination '${p.join(dst, 'a.txt')}'"]); expect(r.exitCode, 0); final job = TransferJob(sources: [src], dest: dst, move: true, contents: true, method: CopyMethod.robocopy, options: '/E'); await job.run(); expect(job.error, isNull); expect(File(p.join(src, 'a.txt')).existsSync(), isFalse, reason: '같은 파일도 옮김'); expect(File(p.join(dst, 'a.txt')).readAsStringSync(), 'a'); expect(job.leftInSource, 0); }); test('217 다시: robocopy 이동 - 만든 시각만 다른 같은 파일 (robocopy 의 "Modified") 도 원본에서 지운다 (실제 Windows 에서 남던 것)', () async { if (!Platform.isWindows) return; final a = p.join(src, 'a.txt'), b = p.join(dst, 'a.txt'); final r = Process.runSync('powershell', [ '-NoProfile', '-Command', "Copy-Item -LiteralPath '$a' -Destination '$b'; (Get-Item -LiteralPath '$b').CreationTime = (Get-Date).AddMinutes(5); " "(Get-Item -LiteralPath '$b').LastWriteTime = (Get-Item -LiteralPath '$a').LastWriteTime", ]); expect(r.exitCode, 0, reason: '${r.stderr}'); final job = TransferJob(sources: [src], dest: dst, move: true, contents: true, method: CopyMethod.robocopy, options: '/E /COPY:DAT /DCOPY:T /R:2 /W:2'); await job.run(); expect(job.error, isNull); expect(File(a).existsSync(), isFalse, reason: 'Modified 도 옮김 (/IM)'); expect(File(b).readAsStringSync(), 'a'); expect(job.leftInSource, 0); }); test('robocopy 이동 (데이터): 원본 안의 junction 을 따라가 원본 바깥 폴더의 파일을 옮기고 지우지 않는다 (/XJ)', () async { if (!Platform.isWindows) return; final outside = Directory(p.join(p.dirname(src), 'outside_${p.basename(src)}'))..createSync(); addTearDown(() => outside.existsSync() ? outside.deleteSync(recursive: true) : null); final keep = File(p.join(outside.path, 'keep.txt'))..writeAsStringSync('k'); Link(p.join(src, 'linked')).createSync(outside.path); // junction final job = TransferJob(sources: [src], dest: dst, move: true, contents: true, method: CopyMethod.robocopy, options: '/E /COPY:DAT /DCOPY:T /R:2 /W:2'); await job.run(); expect(job.error, isNull); expect(keep.existsSync(), isTrue, reason: '링크가 가리키는 바깥 파일은 그대로'); expect(File(p.join(dst, 'linked', 'keep.txt')).existsSync(), isFalse, reason: '링크 안의 것은 옮기지 않음'); expect(Link(p.join(src, 'linked')).existsSync(), isTrue, reason: '링크는 원본에 남김'); expect(File(p.join(src, 'a.txt')).existsSync(), isFalse, reason: '나머지는 옮김'); }); test('robocopy 복사 (데이터): 원본 안의 junction 을 따라가 원본 바깥 파일을 대상에 복사하지 않는다 (/XJ - rsync -a 처럼)', () async { if (!Platform.isWindows) return; final outside = Directory(p.join(p.dirname(src), 'outside_c_${p.basename(src)}'))..createSync(); addTearDown(() => outside.existsSync() ? outside.deleteSync(recursive: true) : null); File(p.join(outside.path, 'keep.txt')).writeAsStringSync('k'); Link(p.join(src, 'linked')).createSync(outside.path); // junction final job = TransferJob(sources: [src], dest: dst, move: false, contents: true, method: CopyMethod.robocopy, options: '/E /COPY:DAT /DCOPY:T /R:2 /W:2'); await job.run(); expect(job.error, isNull); expect(File(p.join(dst, 'a.txt')).existsSync(), isTrue); expect(File(p.join(dst, 'linked', 'keep.txt')).existsSync(), isFalse, reason: '링크 안의 바깥 파일은 복사하지 않음'); }); test('235 · 229: robocopy 이동도 받는 쪽이 더 새것이면 건너뜀 (환경 설정 → /XO) · 남은 것은 카드 기록에도 (남은 수 · 옮긴 수)', () async { // 옵션 칸에는 /XO 가 없다 - 환경 설정 (skipNewer) 만으로 robocopy 가 건너뛴다 final old = File(p.join(src, 'old.txt'))..writeAsStringSync('o'); old.setLastModifiedSync(DateTime.now().subtract(const Duration(days: 2))); File(p.join(dst, 'old.txt')).writeAsStringSync('new'); final job = TransferJob(sources: [src], dest: dst, move: true, contents: true, method: Platform.isWindows ? CopyMethod.robocopy : CopyMethod.builtin, options: Platform.isWindows ? '/E /COPY:DAT /DCOPY:T /R:2 /W:2' : '', skipNewer: true); await job.run(); expect(job.error, isNull); expect(File(p.join(dst, 'old.txt')).readAsStringSync(), 'new', reason: '대상의 더 새 파일 그대로'); expect(old.existsSync(), isTrue, reason: '건너뛴 원본은 남김'); expect(job.leftInSource, 1); expect([job.doneFiles, job.allFiles], [job.allFiles - 1, job.allFiles], reason: '옮긴 수는 남은 것을 뺀다 (robocopy 는 다 했다고 셈)'); final t = CopyTask(id: '1', sources: [src], dest: dst, move: true).copyWith(lastResult: 'done', lastFiles: job.doneFiles, lastLeft: job.leftInSource); expect(CopyTask.fromJson(t.toJson()).lastLeft, 1); }); test('229: 앱 직접 이동 - 받는 쪽이 더 새것이면 덮어쓰지 않고 원본도 남긴다 (환경 설정 처음 값 켜짐)', () async { expect(AppSettings.fromJson({}).copySkipNewer, isTrue); expect(AppSettings.fromJson((AppSettings()..copySkipNewer = false).toJson()).copySkipNewer, isFalse); final old = File(p.join(src, 'older.txt'))..writeAsStringSync('old in src'); old.setLastModifiedSync(DateTime.now().subtract(const Duration(days: 2))); File(p.join(dst, 'older.txt')).writeAsStringSync('NEW version in dst'); final job = TransferJob(sources: [src], dest: dst, move: true, contents: true, skipNewer: true); await job.run(); expect(job.error, isNull); expect(File(p.join(dst, 'older.txt')).readAsStringSync(), 'NEW version in dst', reason: '새 내용 그대로'); expect(old.existsSync(), isTrue, reason: '건너뛴 원본은 이동이라도 남김'); expect(File(p.join(src, 'a.txt')).existsSync(), isFalse, reason: '나머지는 옮김'); expect(job.leftInSource, 1); expect(job.doneSummary(), contains('1개는 원본에 남음')); }); test('229: 건너뛰기를 끄면 (사용자가 고름) 예전처럼 덮어쓴다', () async { final old = File(p.join(src, 'older.txt'))..writeAsStringSync('old in src'); old.setLastModifiedSync(DateTime.now().subtract(const Duration(days: 2))); File(p.join(dst, 'older.txt')).writeAsStringSync('NEW version in dst'); final job = TransferJob(sources: [src], dest: dst, move: false, contents: true, skipNewer: false); await job.run(); expect(File(p.join(dst, 'older.txt')).readAsStringSync(), 'old in src'); }); test('229: 미리 보기 - 건너뛰면 "받는 쪽이 더 새것 - 건너뜀", 아니면 덮어쓸 줄에 "더 새 것" 표시', () async { final old = File(p.join(src, 'older.txt'))..writeAsStringSync('old in src'); old.setLastModifiedSync(DateTime.now().subtract(const Duration(days: 2))); File(p.join(dst, 'older.txt')).writeAsStringSync('NEW version in dst'); final skip = await previewSync(src, dst, toRight: true, update: true); expect([for (final x in skip) if (x.rel == 'older.txt') x.action], [PreviewAction.skipNewer]); final over = await previewSync(src, dst, toRight: true); final o = over.firstWhere((x) => x.rel == 'older.txt'); expect([o.action, o.newer], [PreviewAction.update, true]); }); group('214 b: WebDAV', () { late Directory remote; late TestDavServer server; setUp(() async { remote = Directory(p.join(tmp.path, 'remote'))..createSync(); server = TestDavServer(remote); await server.start(); final s = DavServer(id: 'nas', name: 'NAS', url: server.url, user: 'user', password: 'pass'); DavRegistry.configure([s]); }); tearDown(() async { DavRegistry.configure([]); await server.stop(); }); test('214 b: 크기만 같고 내용이 다른 대상 (대상이 더 새것) 이 있어도, 이동은 다시 보내고 지운다 (내용을 잃지 않게)', () async { HttpOverrides.global = null; File(p.join(src, 'same.txt')) ..writeAsStringSync('AAAA') ..setLastModifiedSync(DateTime(2020)); File(p.join(remote.path, 'same.txt')).writeAsStringSync('BBBB'); // 같은 크기 · 더 새것 · 다른 내용 final job = TransferJob(sources: [src], dest: 'dav://nas/', move: true, contents: true); await job.run(); expect(job.error, isNull); expect(File(p.join(remote.path, 'same.txt')).readAsStringSync(), 'AAAA', reason: '원본 내용이 대상에 (예전에는 같다고 보고 원본만 지웠다)'); expect(File(p.join(src, 'same.txt')).existsSync(), isFalse); expect(File(p.join(remote.path, 'a.txt')).readAsStringSync(), 'a'); }); test('229 (WebDAV 로): 받는 쪽이 더 새것이면 건너뛰고 원본 남김', () async { HttpOverrides.global = null; File(p.join(src, 'older.txt')) ..writeAsStringSync('old in src') ..setLastModifiedSync(DateTime.now().subtract(const Duration(days: 2))); File(p.join(remote.path, 'older.txt')).writeAsStringSync('NEW version in dst'); final job = TransferJob(sources: [src], dest: 'dav://nas/', move: true, contents: true, skipNewer: true); await job.run(); expect(job.error, isNull); expect(File(p.join(remote.path, 'older.txt')).readAsStringSync(), 'NEW version in dst'); expect(File(p.join(src, 'older.txt')).existsSync(), isTrue); expect(File(p.join(src, 'a.txt')).existsSync(), isFalse); }); test('214 d (WebDAV 로): 로컬 원본의 링크는 따라가지 않는다', () async { HttpOverrides.global = null; final outside = Directory(p.join(tmp.path, 'outside2'))..createSync(); final precious = File(p.join(outside.path, 'precious.txt'))..writeAsStringSync('keep me'); try { Link(p.join(src, 'jn')).createSync(outside.path); } on FileSystemException { markTestSkipped('이 환경에서는 링크를 만들 수 없음'); return; } final job = TransferJob(sources: [src], dest: 'dav://nas/', move: true, contents: true); await job.run(); expect(job.error, isNull); expect(precious.existsSync(), isTrue); expect(File(p.join(src, 'a.txt')).existsSync(), isFalse); }); }); }