JJ_MKVMaker/test/batch_rename_test.dart

174 lines
9.9 KiB
Dart

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<String> names() => (tmp.listSync().map((e) => p.basename(e.path)).toList()..sort());
test('78 보충 b: 엮이지 않은 이름 · 이어진 이름 (a→b, b→c) 은 임시 이름 없이 바로, 고리 (a↔b) 만 임시 이름 하나', () async {
final used = <String>[];
Future<String> 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<String> 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<BatchRenameException>().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<NavigatorState>();
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<void>.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<FilledButton>(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<void>.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: '받은 차례대로 번호');
});
});
}