import 'dart:io'; import 'package:archive/archive.dart' show Archive, ArchiveFile, ZipEncoder; import 'package:flutter_test/flutter_test.dart'; import 'package:jj_mkvmaker/app/app_controller.dart'; import 'package:jj_mkvmaker/app/archive_browse.dart'; import 'package:jj_mkvmaker/app/copy_center.dart'; import 'package:jj_mkvmaker/app/file_opener.dart'; import 'package:jj_mkvmaker/app/rsync_runner.dart'; import 'package:jj_mkvmaker/core/app_hidden.dart'; import 'package:jj_mkvmaker/core/file_ops.dart' show FileEntry; import 'package:jj_mkvmaker/core/sync_preview.dart' show PreviewAction; 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:path/path.dart' as p; /// E5c 약속 시험: lib/app/archive_browse.dart · file_opener.dart · rsync_runner.dart · lib/core/app_hidden.dart 맨 위 약속. /// 시험 폴더의 실제 파일 · 실제 ZIP (7z 는 동봉 7-Zip 이 있을 때), 설정은 메모리에만. void main() { late Directory tmp; setUp(() => tmp = Directory.systemTemp.createTempSync('jj_e5c_')); tearDown(() => tmp.deleteSync(recursive: true)); String zipOf(Map files) { final z = p.join(tmp.path, 'pack.zip'); final a = Archive(); files.forEach((k, v) => a.addFile(ArchiveFile.string(k, v))); File(z).writeAsBytesSync(ZipEncoder().encode(a)); return z; } test('압축: 읽기 · 안의 목록 (폴더를 경로에서 만듦 · 위험한 이름 뺌) · 바뀌면 다시 읽고 잊음 알림 · 없어짐 · 하나 꺼내기', () async { final z = zipOf({'2026/beach.jpg': 'jpg', 'readme.txt': 'hello', '../evil.txt': 'x'}); final forgot = []; final b = ArchiveBrowser(() async => null, onForget: forgot.add); expect(ArchiveBrowser.canBrowse(FileEntry(z, isDir: false, modified: DateTime(0))), isTrue); expect(await b.read(z), ArchiveRead.ok); expect([for (final e in b.list(z)) '${p.basename(e.path)}${e.isDir ? '/' : ''}']..sort(), ['2026/', 'readme.txt']); expect(b.list(p.join(z, '2026')).single.path, p.join(z, '2026', 'beach.jpg')); expect([b.inside(p.join(z, 'readme.txt')), b.inside(z)], [true, false]); expect(b.innerName(z, p.join(z, '2026', 'beach.jpg')), '2026/beach.jpg'); final (out, why) = await b.extractOne(p.join(z, 'readme.txt'), tmp.path); expect(why, ArchiveRead.ok); expect(File(out!).readAsStringSync(), 'hello'); // 바뀌면 다시 읽음 (잊음 알림) await Future.delayed(const Duration(milliseconds: 1100)); zipOf({'new.txt': 'n'}); expect(await b.read(z), ArchiveRead.ok); expect(forgot, [z]); expect([for (final e in b.list(z)) p.basename(e.path)], ['new.txt']); File(z).deleteSync(); expect(await b.read(z), ArchiveRead.gone); expect(b.contains(z), isFalse); }); test('압축: 7z · RAR 은 7-Zip 이 없으면 noTool', () async { final s = p.join(tmp.path, 'a.7z'); File(s).writeAsStringSync('not really'); expect(await ArchiveBrowser(() async => null).read(s), ArchiveRead.noTool); }); test('여는 길: 동영상 · 7z · 그림 · PDF · ZIP · HWP · 문서 · 바깥 앱 (켠 컴포넌트 · 기기에 따라)', () { OpenRoute r(String f, {List comps = const ['viewer', 'docs'], bool android = false, bool windows = true, bool hwp = true}) => openRouteFor(f, components: comps, imageExts: const ['jpg', 'png'], android: android, windows: windows, hwpEngine: hwp); expect(r('a.mkv'), OpenRoute.player); expect(r('a.7z'), OpenRoute.extractArchive); expect(r('a.jpg'), OpenRoute.images); expect(r('a.jpg', comps: const []), OpenRoute.external, reason: '보기 컴포넌트가 꺼져 있음'); expect(r('a.pdf'), OpenRoute.pdf); expect(r('a.cbz'), OpenRoute.zip); expect(r('a.hwp', android: true, windows: false), OpenRoute.hwp); expect(r('a.hwp', android: true, windows: false, hwp: false), OpenRoute.external); expect(r('a.docx'), OpenRoute.docPreview); expect(r('a.docx', windows: false), OpenRoute.external); expect(r('a.txt'), OpenRoute.external); }); test('목록에서 숨기기: 같은 경로 (대소문자) · 옮기면 새 경로 · 실제로 없어진 것만 뺌 · 바뀐 것 없으면 null', () async { final d = p.join(tmp.path, 'Show'); Directory(p.join(d, 'sub')).createSync(recursive: true); final keep = p.join(tmp.path, 'keep.txt'); File(keep).writeAsStringSync('k'); final list = [p.join(d, 'sub'), keep, p.join(tmp.path, 'gone.txt')]; final h = HiddenList(); expect(h.isHidden(list, Platform.isWindows ? keep.toUpperCase() : keep), isTrue); expect(h.isHidden(list, p.join(tmp.path, 'x')), isFalse); final moved = await HiddenList.follow(list, moved: [(d, p.join(tmp.path, 'Season'))], gone: [tmp.path]); expect(moved, [p.join(tmp.path, 'Season', 'sub'), keep], reason: 'gone.txt 은 없음 → 뺌, keep.txt 은 있음 → 남김'); expect(await HiddenList.follow(list, moved: [(p.join(tmp.path, 'zzz'), p.join(tmp.path, 'yyy'))]), isNull); }); test('Rsync 실행: 막는 이유 · 쌍 · ⇄ 의 옵션 (← 는 지우지 않음 · -u) · 원본 지우기 · 미리 보기 (실제 폴더)', () async { final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); c.settings.copy.rsyncOptions = '-a --delete'; final center = CopyCenter.of(c); final l = p.join(tmp.path, 'L'), r = p.join(tmp.path, 'R'); Directory(l).createSync(); Directory(r).createSync(); File(p.join(l, 'a.txt')).writeAsStringSync('a'); File(p.join(r, 'old.txt')).writeAsStringSync('o'); ({RsyncRun? run, String? problem, bool missing}) prep(String? a, String? b, {bool right = true, bool left = false, bool inner = true}) => RsyncRun.prepare(center, l: a, r: b, right: right, left: left, allowInnerToOuter: inner, jobs: const [], viaDav: false, copySkipNewer: false); expect(prep(null, r).problem, isNotNull); expect(prep(l, p.join(l, 'in')).problem, isNotNull, reason: '대상이 원본 안'); Directory(p.join(r, 'sub')).createSync(); expect(prep(p.join(r, 'sub'), r, inner: false).problem, isNotNull, reason: '원본이 대상 안 (설정이 막음)'); expect(prep(p.join(r, 'sub'), r).problem, RsyncRun.nestedDeleteMessage, reason: '원본이 대상 안 + --delete'); final gone = prep(p.join(tmp.path, 'none'), r); expect([gone.problem != null, gone.missing], [true, true]); final one = prep(l, r).run!; expect([one.runs.length, one.both, one.deletes()], [1, false, true]); final items = await one.preview(); expect(items.any((x) => x.action == PreviewAction.delete), isTrue, reason: '실제 폴더 비교: 받는 쪽에만 있는 것은 지움'); final two = prep(l, r, left: true).run!; expect(two.runOpts(0), contains('-u')); expect(two.runOpts(1), isNot(contains('--delete')), reason: '← 에서는 지우지 않음'); one.setRemoveSource(true); expect([one.removeSource, one.tasks.single.move], [true, true]); }); }