옮기기: 시작하면 바로 목록에서 빼고, 옮기지 못하면 원래 자리 · 표시 · 선택으로 되돌리기

- 다른 드라이브 · NAS 로는 복사라 큰 동영상이 1초 넘게 목록에 남아 보였다 (1GB 시험 파일 M: → C: 1,079ms, 같은 드라이브 22ms).
- 데이터 버그 고침 (moveFileInto): 다른 드라이브로 복사한 뒤 원본을 지우지 못하면 (열려 있음 등) 대상 폴더에 사본이
  남아 두 벌이 됐다. 이제 방금 만든 사본을 지우고 오류를 낸다 (원본은 그대로).
  같은 함수를 쓰는 app_controller 안의 다른 두 곳 (옆 자막 · jj_mkv 결과물 함께 옮기기) 도 시험으로 확인했다:
  자막 · 결과물을 열어 둔 채 옮기면 동영상만 옮겨지고, 자막 · 결과물은 원래 자리에 하나만 남는다
  (고치기 전 코드로는 이 시험이 실패하는 것도 확인).
- move_test: 옮기는 중 목록에 없음 → 실패하면 복원 · 대상에 사본 없음, 함께 옮기는 파일의 실패.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
묶음4
RyuJongJin 2026-10-10 00:37:43 +09:00
parent bd0b75ce7b
commit abeb3d8999
2 changed files with 80 additions and 4 deletions

View File

@ -525,6 +525,15 @@ class AppController extends ChangeNotifier {
errors.add(trf('{0}: 이미 이동 폴더에 있습니다', [v.fileName])); errors.add(trf('{0}: 이미 이동 폴더에 있습니다', [v.fileName]));
continue; continue;
} }
// 옮기기 시작하면 바로 목록에서 뺀다 (다른 드라이브 · NAS 로는 복사라 큰 동영상은 몇 초 넘게 남아 보였다).
// 옮기지 못하면 원래 자리 · 표시 · 선택으로 되돌린다.
final at = videos.indexOf(v);
final wasChecked = checked.contains(v);
final wasSelected = selected == v;
videos.remove(v);
checked.remove(v);
if (wasSelected) selected = videos.elementAtOrNull(at) ?? videos.lastOrNull;
notifyListeners();
try { try {
final side = { final side = {
for (final s in v.subtitles) for (final s in v.subtitles)
@ -551,11 +560,15 @@ class AppController extends ChangeNotifier {
if (results > 0) trf(' (결과물 {0}개 함께)', [results]), if (results > 0) trf(' (결과물 {0}개 함께)', [results]),
].join(), ].join(),
])); ]));
videos.remove(v);
checked.remove(v);
moved++; moved++;
} catch (e) { } catch (e) {
errors.add('${v.fileName}: $e'); errors.add('${v.fileName}: $e');
if (File(v.path).existsSync() && !videos.contains(v)) {
videos.insert(at.clamp(0, videos.length), v);
if (wasChecked) checked.add(v);
if (wasSelected) selected = v;
notifyListeners();
}
} }
} }
if (selected != null && !videos.contains(selected)) selected = videos.firstOrNull; if (selected != null && !videos.contains(selected)) selected = videos.firstOrNull;
@ -594,8 +607,16 @@ class AppController extends ChangeNotifier {
try { try {
await File(from).rename(to); await File(from).rename(to);
} on FileSystemException { } on FileSystemException {
await File(from).copy(to); try {
await File(from).delete(); await File(from).copy(to);
await File(from).delete();
} catch (_) {
// 원본을 지우지 못하면 (열려 있음 등) 옮긴 것이 아니다 - 방금 만든 사본을 지워 두 벌이 남지 않게 (원본은 그대로)
try {
if (File(from).existsSync()) await File(to).delete();
} catch (_) {}
rethrow;
}
} }
return to; return to;
} }

View File

@ -61,6 +61,61 @@ void main() {
expect(c.selected, busy); expect(c.selected, busy);
}); });
test('옮기기 시작하면 바로 목록에서 빠지고, 옮기지 못하면 원래 자리 · 표시 · 선택으로 돌아온다', () async {
final c = _controller();
final dest = p.join(dir.path, 'done');
final a = VideoItem(make('a.mp4').path);
final b = VideoItem(make('b.mp4').path);
final d = VideoItem(make('d.mp4').path);
c.videos.addAll([a, b, d]);
c.checked.add(b);
c.selected = b;
// 처음 알릴 때 (옮기는 중) 이미 목록에 없다
List<String>? whileMoving;
void first() => whileMoving ??= c.videos.map((v) => v.fileName).toList();
c.addListener(first);
// Windows: 열려 있는 파일은 옮길 수 없다
final h = File(b.path).openSync(mode: FileMode.append);
try {
final (moved, errors) = await c.moveVideos([b], dest);
expect(moved, 0);
expect(errors.single, contains('b.mp4'));
} finally {
h.closeSync();
c.removeListener(first);
}
expect(whileMoving, ['a.mp4', 'd.mp4']);
expect(c.videos, [a, b, d]);
expect(c.checked, {b});
expect(c.selected, b);
expect(File(b.path).existsSync(), isTrue);
expect(File(p.join(dest, 'b.mp4')).existsSync(), isFalse, reason: '옮기다 만 사본을 남기지 않는다');
}, skip: !Platform.isWindows);
test('함께 옮기는 자막 · 결과물을 옮기지 못하면 원본은 그대로 두고 대상에 사본을 남기지 않는다', () async {
final c = _controller();
final dest = p.join(dir.path, 'done');
final a = VideoItem(make('a.mp4').path)
..subtitles.add(SubtitleEntry.external(path: make('a.ko.srt').path, language: undetermined));
make(p.join('jj_mkv', 'a.mkv'));
c.videos.add(a);
// Windows: 열려 있는 파일은 옮길 수 없다 (옆 자막 · jj_mkv 결과물)
final h1 = File(p.join(dir.path, 'a.ko.srt')).openSync(mode: FileMode.append);
final h2 = File(p.join(dir.path, 'jj_mkv', 'a.mkv')).openSync(mode: FileMode.append);
try {
final (moved, _) = await c.moveVideos([a], dest);
expect(moved, 1, reason: '동영상은 옮긴다');
} finally {
h1.closeSync();
h2.closeSync();
}
expect(File(p.join(dest, 'a.mp4')).existsSync(), isTrue);
expect(File(p.join(dir.path, 'a.ko.srt')).existsSync(), isTrue);
expect(File(p.join(dest, 'a.ko.srt')).existsSync(), isFalse, reason: '자막 사본이 두 벌로 남지 않는다');
expect(File(p.join(dir.path, 'jj_mkv', 'a.mkv')).existsSync(), isTrue);
expect(File(p.join(dest, 'jj_mkv', 'a.mkv')).existsSync(), isFalse, reason: '결과물 사본이 두 벌로 남지 않는다');
}, skip: !Platform.isWindows);
test('목록에 다시 넣으면 (앱을 다시 켜도) 전에 만든 자막 (jj_mkv 의 파일명_ko.srt 등) 이 다시 붙는다', () async { test('목록에 다시 넣으면 (앱을 다시 켜도) 전에 만든 자막 (jj_mkv 의 파일명_ko.srt 등) 이 다시 붙는다', () async {
final c = _controller(); final c = _controller();
final video = make('강의.mp4').path; final video = make('강의.mp4').path;