import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:jj_mkvmaker/platform/windows/recycle_bin.dart'; import 'package:path/path.dart' as p; import 'support/fake_recycle_bin.dart'; import 'support/recycle_leftovers.dart'; /// 시험이 휴지통에 넣은 것만 (원래 위치가 이 시험의 임시 폴더인 항목) 휴지통에서 지운다. 사용자 항목은 건드리지 않는다. Future _purgeFromRecycleBin(String fromDir) async { final dir = fromDir.replaceAll("'", "''"); final r = await Process.run('powershell.exe', [ '-NoProfile', '-NonInteractive', '-Command', ''' \$rb = (New-Object -ComObject Shell.Application).Namespace(10) \$n = 0 foreach (\$it in @(\$rb.Items())) { if (\$rb.GetDetailsOf(\$it, 1) -ne '$dir') { continue } \$path = \$it.Path; \$d = Split-Path \$path; \$leaf = Split-Path \$path -Leaf Remove-Item -LiteralPath \$path -Recurse -Force -Confirm:\$false \$info = Join-Path \$d ('\$I' + \$leaf.Substring(2)) if (Test-Path -LiteralPath \$info) { Remove-Item -LiteralPath \$info -Force -Confirm:\$false } \$n++ } \$n ''', ]); return int.tryParse('${r.stdout}'.trim().split('\n').last.trim()) ?? -1; } /// 65 · 94 · 98: Windows 휴지통 (실제 휴지통 - 시험 파일 하나 · 폴더 하나, 끝나면 휴지통에서도 지움). /// 실제 휴지통을 쓰는 시험은 JJ_TEST_REAL_RECYCLE=1 일 때만 (전체 시험 중에 끊기면 사용자 휴지통에 흔적이 남으므로). /// 휴지통 쪽 코드를 고칠 때 · 릴리스 전에 따로: JJ_TEST_REAL_RECYCLE=1 flutter test test/recycle_bin_test.dart void main() { test('파일 · 폴더를 휴지통으로 (실제로 들어갔는지까지) · 없는 것은 그냥 성공', () async { final tmp = Directory.systemTemp.createTempSync('jj_recycle_'); addTearDown(() async { expect(await recycleLeftovers(tmp.path), 0, reason: '사용자 휴지통에 시험 흔적 없음 (하위 폴더 포함)'); if (tmp.existsSync()) tmp.deleteSync(recursive: true); }); final f = File(p.join(tmp.path, 'jj_recycle_test.txt'))..writeAsStringSync('x'); final d = Directory(p.join(tmp.path, 'jj_recycle_dir'))..createSync(); File(p.join(d.path, 'a.txt')).writeAsStringSync('a'); try { expect(hasRecycleBin(f.path), isTrue); // 임시 폴더는 고정 디스크 expect(moveToRecycleBin(f.path), RecycleResult.recycled); // 수를 세지 않고 휴지통 안의 정보 파일 ($I, 원래 경로) 로 확인할 수 있다 expect(recycledInfoExists(f.absolute.path, since: DateTime.now().subtract(const Duration(minutes: 1))), isTrue); expect(f.existsSync(), isFalse); expect(moveToRecycleBin(d.path), RecycleResult.recycled); expect(d.existsSync(), isFalse); expect(moveToRecycleBin(p.join(tmp.path, 'none.txt')), RecycleResult.recycled); } finally { // 사용자 휴지통에 시험 항목을 남기지 않는다 expect(await _purgeFromRecycleBin(tmp.absolute.path), 2); } }, skip: realRecycleSkip); test('148: 휴지통으로 보낸 파일 · 폴더를 원래 자리로 되돌린다 (휴지통에 남지 않음) · 원래 자리에 같은 이름이 있으면 덮지 않음', () async { final tmp = Directory.systemTemp.createTempSync('jj_undo_'); addTearDown(() async { await _purgeFromRecycleBin(tmp.absolute.path); // 실패해 남은 것이 있으면 사용자 휴지통에서 지운다 expect(await recycleLeftovers(tmp.path), 0, reason: '사용자 휴지통에 시험 흔적 없음 (하위 폴더 포함)'); if (tmp.existsSync()) tmp.deleteSync(recursive: true); }); final since = DateTime.now().subtract(const Duration(seconds: 2)); final f = File(p.join(tmp.absolute.path, 'undo.txt'))..writeAsStringSync('keep me'); final d = Directory(p.join(tmp.absolute.path, 'undo_dir'))..createSync(); File(p.join(d.path, 'a.txt')).writeAsStringSync('a'); expect(moveToRecycleBin(f.path), RecycleResult.recycled); expect(moveToRecycleBin(d.path), RecycleResult.recycled); expect(f.existsSync() || d.existsSync(), isFalse); restoreFromRecycleBin(f.path, since: since); restoreFromRecycleBin(d.path, since: since); expect(f.readAsStringSync(), 'keep me'); expect(File(p.join(d.path, 'a.txt')).readAsStringSync(), 'a'); expect(recycledInfoExists(f.path, since: since), isFalse, reason: '휴지통에 남지 않음'); // 원래 자리에 같은 이름이 생겼으면 덮지 않고 실패 (휴지통의 것은 그대로) expect(moveToRecycleBin(f.path), RecycleResult.recycled); f.writeAsStringSync('new one'); expect(() => restoreFromRecycleBin(f.path, since: since), throwsA(isA())); expect(f.readAsStringSync(), 'new one'); expect(recycledInfoExists(f.path, since: since), isTrue); // 휴지통에 없으면 실패 expect(() => restoreFromRecycleBin(p.join(tmp.absolute.path, 'never.txt'), since: since), throwsA(isA())); }, skip: realRecycleSkip); test('148: 원래 폴더가 그사이 지워졌으면 다시 만들지 않고 알린다 · 이 사용자 (SID) 의 휴지통에서만 찾는다', () async { expect(currentUserSid(), matches(RegExp(r'^S-1-5-'))); final tmp = Directory.systemTemp.createTempSync('jj_undo_'); final folder = Directory(p.join(tmp.absolute.path, 'gone'))..createSync(); addTearDown(() async { // 휴지통에 남긴 x.txt 의 원래 위치는 gone 폴더 (정확히 그 폴더로 찾아 지운다) expect(await _purgeFromRecycleBin(folder.path), 1); expect(await recycleLeftovers(tmp.path), 0, reason: '사용자 휴지통에 시험 흔적 없음 (하위 폴더 포함)'); if (tmp.existsSync()) tmp.deleteSync(recursive: true); }); final since = DateTime.now().subtract(const Duration(seconds: 2)); final f = File(p.join(folder.path, 'x.txt'))..writeAsStringSync('x'); expect(moveToRecycleBin(f.path), RecycleResult.recycled); folder.deleteSync(); // 사용자가 그 폴더를 지움 expect( () => restoreFromRecycleBin(f.path, since: since), throwsA(isA() .having((e) => e.message, 'message', contains('원래 폴더가 없어 되돌리지 못했습니다')))); expect(folder.existsSync(), isFalse, reason: '지운 폴더를 몰래 다시 만들지 않음'); expect(recycledInfoExists(f.path, since: since), isTrue, reason: '휴지통의 것은 그대로'); }, skip: realRecycleSkip); test('긴 경로 (260자 넘음) 는 휴지통으로 보내지 않고 이유를 알린다 - 묻지 않고 영구 삭제되던 것 (폴더 안에 있어도)', () { if (!Platform.isWindows) return; // 긴 경로를 만들고 지우려면 앞에 \\?\ 를 붙인다 String lp(String s) => r'\\?\' + s; final tmp = Directory.systemTemp.createTempSync('jj_rb_long_'); addTearDown(() => Directory(lp(tmp.path)).deleteSync(recursive: true)); var deep = p.join(tmp.path, 'outer'); while (deep.length < 250) { deep = p.join(deep, 'd' * 30); } final file = p.join(deep, 'a_file_name_that_makes_it_longer.txt'); Directory(lp(deep)).createSync(recursive: true); File(lp(file)).writeAsStringSync('x'); expect(file.length, greaterThan(259)); expect(longPathInside(file), file); // 실제 휴지통 함수는 JJ_TEST_REAL_RECYCLE=1 일 때만 (고장 나면 사용자 휴지통에 들어가므로) if (realRecycleBin) { expect(() => moveToRecycleBin(file), throwsA(isA().having((e) => e.message, 'message', contains('경로가 너무 깁니다')))); expect(File(lp(file)).existsSync(), isTrue, reason: '지우지 않음'); } // 폴더 자체는 짧아도 안에 긴 경로가 있으면 final outer = p.join(tmp.path, 'outer'); expect(longPathInside(outer), isNotNull); if (realRecycleBin) { expect(() => moveToRecycleBin(outer), throwsA(isA())); expect(Directory(outer).existsSync(), isTrue); } final short = File(p.join(tmp.path, 'short.txt'))..writeAsStringSync('x'); expect(longPathInside(short.path), isNull, reason: '짧은 파일 경로'); }); test(r'휴지통 정보 파일 ($I) 에서 원래 경로 읽기 - Windows 10 이상 (판 2) · 예전 (판 1)', () { List le(int v, int n) => [for (var i = 0; i < n; i++) v >> (8 * i) & 0xff]; List utf16(String s) => [for (final u in s.codeUnits) ...le(u, 2)]; const path = r'C:\영상\a b.mkv'; final v2 = [...le(2, 8), ...le(1234, 8), ...le(0, 8), ...le(path.length + 1, 4), ...utf16(path), 0, 0]; expect(recycledInfoPath(v2), path); final v1 = [...le(1, 8), ...le(1234, 8), ...le(0, 8), ...utf16(path), ...List.filled(520 - path.length * 2, 0)]; expect(recycledInfoPath(v1), path); expect(recycledInfoPath([9, 9, 9]), isNull); expect(recycledInfoPath([...le(7, 8), ...List.filled(40, 0)]), isNull); }); test('94: 휴지통이 없는 곳 (네트워크 공유 · 없는 드라이브) 은 휴지통으로 보내지 않는다 · 98: 이유는 읽을 수 있는 말', () { expect(hasRecycleBin(r'\\NAS\share\a.mkv'), isFalse); // 쓰지 않는 드라이브 문자 final unused = 'QRSTUVWXYZ'.split('').firstWhere((l) => !Directory('$l:\\').existsSync(), orElse: () => ''); if (unused.isNotEmpty) expect(hasRecycleBin('$unused:\\a.mkv'), isFalse); expect(recycleErrorText(0x7C), contains('경로가 너무 깁니다')); expect(recycleErrorText(0x20), contains('다른 프로그램')); expect(recycleErrorText(0x12345), contains('0x12345')); }, skip: !Platform.isWindows); }