import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:jj_mkvmaker/core/batch_rename.dart'; import 'package:jj_mkvmaker/core/file_ops.dart'; import 'package:jj_mkvmaker/ui/batch_rename_dialog.dart'; import 'package:path/path.dart' as p; /// 78: 일괄 이름 바꾸기 - 번호 [C] · 찾아 바꾸기 · 대소문자, 미리 보기 (겹침 · 이미 있음), 맞바꾸기, 실패하면 되돌림, 되돌리기 void main() { group('규칙', () { test('드라마 회차: "Show S01E[C]" - 확장자는 그대로, 번호는 자릿수만큼', () { const r = RenameRule(template: 'Show S01E[C]'); expect(r.apply('아무거나.mkv', isDir: false, index: 0), 'Show S01E01.mkv'); expect(r.apply('x.srt', isDir: false, index: 9), 'Show S01E10.srt'); expect(const RenameRule(template: '[C] - [N]', start: 5, step: 5, digits: 3).apply('a.mp4', isDir: false, index: 2), '015 - a.mp4'); }); test('찾아 바꾸기 (대소문자 무시 · 가리기) · 대소문자 바꾸기 · 폴더는 점 뒤도 이름', () { expect(const RenameRule(find: 'x264', replace: 'H264').apply('Movie.X264.mkv', isDir: false, index: 0), 'Movie.H264.mkv'); expect(const RenameRule(find: 'x264', replace: '', ignoreCase: false).apply('Movie.X264.mkv', isDir: false, index: 0), 'Movie.X264.mkv'); expect(const RenameRule(caseMode: 'lower').apply('ABC.MKV', isDir: false, index: 0), 'abc.mkv'); expect(const RenameRule(caseMode: 'upper').apply('abc.mkv', isDir: false, index: 0), 'ABC.MKV'); expect(const RenameRule(caseMode: 'title').apply('the big show.mkv', isDir: false, index: 0), 'The Big Show.mkv'); expect(const RenameRule(template: '[N]_v2').apply('Season.1', isDir: true, index: 0), 'Season.1_v2'); expect(const RenameRule().isIdentity, isTrue); }); }); group('미리 보기', () { final dir = p.join('C:', 'v'); String at(String n) => p.join(dir, n); test('같은 이름 겹침 · 고르지 않은 것과 겹침 · 쓸 수 없는 글자 · 맞바꾸기는 괜찮음', () { final rows = planRename([at('a.mkv'), at('b.mkv')], const RenameRule(template: 'same'), isDir: (_) => false, siblings: {dir: {'a.mkv', 'b.mkv'}}); expect(rows.every((r) => r.problem == '다른 항목과 같은 이름'), isTrue); final taken = planRename([at('a.mkv')], const RenameRule(template: 'c'), isDir: (_) => false, siblings: {dir: {'a.mkv', 'C.MKV'}}); expect(taken.single.problem, '같은 이름이 이미 있음', reason: '대소문자만 다른 것도 겹친다'); final bad = planRename([at('a.mkv')], const RenameRule(template: 'a:b'), isDir: (_) => false, siblings: {dir: {'a.mkv'}}); expect(bad.single.problem, '쓸 수 없는 이름'); // a.mkv → b.mkv, b.mkv → a.mkv (찾아 바꾸기로 맞바꾸지는 못하니 틀로 흉내: 번호 차례를 거꾸로) final swap = planRename([at('b.mkv'), at('a.mkv')], const RenameRule(find: 'b', replace: 'a'), isDir: (_) => false, siblings: {dir: {'a.mkv', 'b.mkv'}}); expect(swap.first.newName, 'a.mkv'); expect(swap.first.problem, '다른 항목과 같은 이름', reason: 'a.mkv 는 그대로 a.mkv 라 겹친다'); }); test('대소문자만 바꾸는 것은 자기 자신과 겹치지 않는다', () { final rows = planRename([at('abc.mkv')], const RenameRule(caseMode: 'upper'), isDir: (_) => false, siblings: {dir: {'abc.mkv'}}); expect(rows.single.problem, isNull); expect(rows.single.newName, 'ABC.MKV'); }); }); // 78 보충 (관리자 판단 a): Windows 에서 쓸 수 없는 이름 test('이름 검사: 예약 이름 (확장자 붙어도) · 끝 점 · 끝 빈칸 · 255자 넘음 - Windows 규칙일 때만', () { for (final n in ['CON', 'con.txt', 'Nul.mkv', 'LPT1', 'com9.srt', 'a.', 'a ', 'x' * 256]) { expect(nameProblem(n, windowsRules: true), isNotNull, reason: n); expect(nameProblem(n, windowsRules: false), isNull, reason: '$n (Android · WebDAV 는 지금처럼)'); } for (final n in ['CONSOLE.txt', 'com10', 'a.b', 'x' * 255, 'Show S01E01.mkv']) { expect(nameProblem(n, windowsRules: true), isNull, reason: n); } expect(nameProblem('a:b', windowsRules: false), '쓸 수 없는 이름', reason: '쓸 수 없는 글자는 어디서나'); expect(nameProblem('', windowsRules: false), '쓸 수 없는 이름'); }); group('바꾸기 (실제 파일)', () { late Directory tmp; setUp(() => tmp = Directory.systemTemp.createTempSync('jj_ren78_')); tearDown(() => tmp.deleteSync(recursive: true)); String mk(String n, [String body = '']) => (File(p.join(tmp.path, n))..writeAsStringSync(body.isEmpty ? n : body)).path; List names() => (tmp.listSync().map((e) => p.basename(e.path)).toList()..sort()); test('78 보충 b: 엮이지 않은 이름 · 이어진 이름 (a→b, b→c) 은 임시 이름 없이 바로, 고리 (a↔b) 만 임시 이름 하나', () async { final used = []; Future spy(String path, String name) { used.add(name); return FileOps.rename(path, name); } final x = mk('x.txt'), a = mk('a.txt'), b = mk('b.txt'); await runBatchRename([(x, 'y.txt'), (a, 'b.txt'), (b, 'c.txt')], renamer: spy); expect(used.where(isAppTempName), isEmpty, reason: '$used'); expect(used.indexOf('c.txt'), lessThan(used.indexOf('b.txt')), reason: 'b 가 먼저 비켜야 a 가 b 로'); expect(names(), ['b.txt', 'c.txt', 'y.txt']); used.clear(); await runBatchRename([(p.join(tmp.path, 'b.txt'), 'c.txt'), (p.join(tmp.path, 'c.txt'), 'b.txt')], renamer: spy); expect(used.where(isAppTempName), hasLength(1), reason: '$used'); expect(File(p.join(tmp.path, 'c.txt')).readAsStringSync(), 'a.txt'); }); test('Windows: 미리 보기에서 예약 이름 · 끝 점을 빨갛게 (바꾸기 전에)', () { final a = mk('a.txt'); final rows = planRename([a], const RenameRule(template: 'CON'), isDir: (_) => false, siblings: {tmp.path: {'a.txt'}}); expect(rows.single.problem, Platform.isWindows ? 'Windows 가 쓰는 이름 (CON)' : isNull); final dir = Directory(p.join(tmp.path, 'd'))..createSync(); final dots = planRename([dir.path], const RenameRule(template: 'end.'), isDir: (_) => true, siblings: {tmp.path: {'d'}}); expect(dots.single.problem, Platform.isWindows ? '이름 끝에 점 · 빈칸은 쓸 수 없음' : isNull); }, skip: !Platform.isWindows); test('여러 개 · 맞바꾸기 (a↔b) · 되돌리기', () async { final a = mk('a.txt'), b = mk('b.txt'), c = mk('c.txt'); final done = await runBatchRename([(a, 'b.txt'), (b, 'a.txt'), (c, 'Show 01.txt')]); expect(names(), ['Show 01.txt', 'a.txt', 'b.txt']); expect(File(p.join(tmp.path, 'b.txt')).readAsStringSync(), 'a.txt', reason: '내용이 바뀐 이름을 따라감'); expect(File(p.join(tmp.path, 'a.txt')).readAsStringSync(), 'b.txt'); // 되돌리기 await runBatchRename([for (final (orig, now) in done) (now, p.basename(orig))]); expect(names(), ['a.txt', 'b.txt', 'c.txt']); expect(File(p.join(tmp.path, 'a.txt')).readAsStringSync(), 'a.txt'); }); test('도중에 실패하면 바꾼 것을 모두 원래대로 (임시 이름이 남지 않음)', () async { final a = mk('a.txt'), b = mk('b.txt'), c = mk('c.txt'); var calls = 0; Future flaky(String path, String name) { // 세 번째 바꾸기에서 실패 (앞의 둘은 이미 바뀜) if (++calls == 3) throw const FileSystemException('잠김'); return FileOps.rename(path, name); } await expectLater( runBatchRename([(a, 'x.txt'), (b, 'y.txt'), (c, 'z.txt')], renamer: flaky), throwsA(isA().having((e) => e.stuck, '되돌리지 못한 것', isEmpty)), ); expect(names(), ['a.txt', 'b.txt', 'c.txt']); }); testWidgets('창: 규칙을 넣으면 미리 보기, 문제가 있으면 [바꾸기] 꺼짐, 바꾸면 결과를 돌려준다', (tester) async { tester.view.physicalSize = const Size(1000, 900); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); final paths = [mk('ep_b.mkv'), mk('ep_a.mkv')]; mk('Show 01.mkv', 'other'); List<(String, String)>? result; final nav = GlobalKey(); await tester.pumpWidget(MaterialApp(navigatorKey: nav, home: const Scaffold())); showBatchRename(nav.currentContext!, paths, isDir: (_) => false).then((r) => result = r); for (var i = 0; i < 20; i++) { await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 20))); await tester.pump(); } expect(find.text('이름 일괄 바꾸기 · 2개'), findsOneWidget); expect(find.text('바뀌는 것 0개'), findsOneWidget); await tester.enterText(find.widgetWithText(TextField, '이름 틀'), 'Show [C]'); await tester.pump(); // 첫 번째가 Show 01.mkv - 고르지 않은 파일과 겹친다 expect(find.textContaining('같은 이름이 이미 있음'), findsOneWidget); expect(tester.widget(find.widgetWithText(FilledButton, '바꾸기 (2개)')).onPressed, isNull); await tester.enterText(find.widgetWithText(TextField, '번호 시작'), '2'); await tester.pump(); expect(find.text('바뀌는 것 2개'), findsOneWidget); await tester.tap(find.widgetWithText(FilledButton, '바꾸기 (2개)')); for (var i = 0; i < 20 && result == null; i++) { await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 20))); await tester.pump(); } expect(result, hasLength(2)); expect(names(), ['Show 01.mkv', 'Show 02.mkv', 'Show 03.mkv']); expect(File(p.join(tmp.path, 'Show 02.mkv')).readAsStringSync(), 'ep_b.mkv', reason: '받은 차례대로 번호'); }); }); }