실시간 동기화 지울지 묻는 창: --delete-excluded 로 지워지는 거른 것은 따로 묶어 빨갛게 (검토 대기)
관리자 3a 조건 (10/20, 954703c 의 판단 ③ 을 받아들이며): 사용자가 알고 누르게.
- diff · _diffV · _gone 이 지울 것 가운데 --delete-excluded 때문에 지워지는 것 (거른 것 · 거른 것이 든 폴더) 을 따로 모음
→ LiveSync.toDeleteExcluded. 확인 창 (confirmLiveDeletes) 은 그냥 지울 것 다음에 "거른 것 n개도 지워집니다
(--delete-excluded)" · 설명 (지키려면 옵션에서 빼기) · 그 목록을 빨갛게. 번역 en · ja · zh-Hans.
시험: live_sync_scene_test - 28 장면마다 따로 묶은 것 (없어야 할 때 없음 · 지울 목록 안 · *.bak / *.log 장면은 정확히),
창의 묶음 제목 · 순서. 캡처: windows_live_excl_shot_test (ko · en · ja × 412 · 960, 넘침 0) → release\test\shots_live_excl.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
next6
parent
954703c4f0
commit
44a6c4e6b2
|
|
@ -465,6 +465,7 @@
|
||||||
"rsync 양쪽 (⇄)": "rsync both ways (⇄)",
|
"rsync 양쪽 (⇄)": "rsync both ways (⇄)",
|
||||||
"rsync 에 고르기": "Pick for rsync",
|
"rsync 에 고르기": "Pick for rsync",
|
||||||
"rsync 옵션": "rsync options",
|
"rsync 옵션": "rsync options",
|
||||||
|
"rsync 옵션에서 뺀 것 (--exclude 등) 이지만 --delete-excluded 가 있어 대상에서 지웁니다. 지키려면 옵션에서 --delete-excluded 를 빼세요.": "These are left out by the rsync options (--exclude etc.), but --delete-excluded deletes them from the target. To keep them, remove --delete-excluded from the options.",
|
||||||
"rsync 종료 코드 {0}": "rsync exit code {0}",
|
"rsync 종료 코드 {0}": "rsync exit code {0}",
|
||||||
"settings.json 없음": "settings.json missing",
|
"settings.json 없음": "settings.json missing",
|
||||||
"stable-diffusion.cpp (Windows · CPU · Vulkan)": "stable-diffusion.cpp (Windows · CPU · Vulkan)",
|
"stable-diffusion.cpp (Windows · CPU · Vulkan)": "stable-diffusion.cpp (Windows · CPU · Vulkan)",
|
||||||
|
|
@ -695,6 +696,7 @@
|
||||||
"개인 키의 암호가 맞지 않습니다": "The private key passphrase is incorrect",
|
"개인 키의 암호가 맞지 않습니다": "The private key passphrase is incorrect",
|
||||||
"거꾸로": "Reverse",
|
"거꾸로": "Reverse",
|
||||||
"거꾸로 된 범위: {0}": "Reversed range: {0}",
|
"거꾸로 된 범위: {0}": "Reversed range: {0}",
|
||||||
|
"거른 것 {0}개도 지워집니다 (--delete-excluded)": "{0} excluded items will also be deleted (--delete-excluded)",
|
||||||
"건너뛰기": "Skip",
|
"건너뛰기": "Skip",
|
||||||
"건너뛴 링크 {0}개": "{0} links skipped",
|
"건너뛴 링크 {0}개": "{0} links skipped",
|
||||||
"건너뜀 (이미 있음): {0}": "Skipped (already exists): {0}",
|
"건너뜀 (이미 있음): {0}": "Skipped (already exists): {0}",
|
||||||
|
|
|
||||||
|
|
@ -465,6 +465,7 @@
|
||||||
"rsync 양쪽 (⇄)": "rsync 双方向 (⇄)",
|
"rsync 양쪽 (⇄)": "rsync 双方向 (⇄)",
|
||||||
"rsync 에 고르기": "rsync に選ぶ",
|
"rsync 에 고르기": "rsync に選ぶ",
|
||||||
"rsync 옵션": "rsync オプション",
|
"rsync 옵션": "rsync オプション",
|
||||||
|
"rsync 옵션에서 뺀 것 (--exclude 등) 이지만 --delete-excluded 가 있어 대상에서 지웁니다. 지키려면 옵션에서 --delete-excluded 를 빼세요.": "rsync オプションで除外したもの (--exclude など) ですが、--delete-excluded があるため対象から削除します。残すにはオプションから --delete-excluded を外してください。",
|
||||||
"rsync 종료 코드 {0}": "rsync 終了コード {0}",
|
"rsync 종료 코드 {0}": "rsync 終了コード {0}",
|
||||||
"settings.json 없음": "settings.json がありません",
|
"settings.json 없음": "settings.json がありません",
|
||||||
"stable-diffusion.cpp (Windows · CPU · Vulkan)": "stable-diffusion.cpp (Windows・CPU・Vulkan)",
|
"stable-diffusion.cpp (Windows · CPU · Vulkan)": "stable-diffusion.cpp (Windows・CPU・Vulkan)",
|
||||||
|
|
@ -695,6 +696,7 @@
|
||||||
"개인 키의 암호가 맞지 않습니다": "秘密鍵のパスフレーズが正しくありません",
|
"개인 키의 암호가 맞지 않습니다": "秘密鍵のパスフレーズが正しくありません",
|
||||||
"거꾸로": "逆順",
|
"거꾸로": "逆順",
|
||||||
"거꾸로 된 범위: {0}": "逆順の範囲: {0}",
|
"거꾸로 된 범위: {0}": "逆順の範囲: {0}",
|
||||||
|
"거른 것 {0}개도 지워집니다 (--delete-excluded)": "除外したもの {0} 件も削除されます (--delete-excluded)",
|
||||||
"건너뛰기": "スキップ",
|
"건너뛰기": "スキップ",
|
||||||
"건너뛴 링크 {0}개": "スキップしたリンク {0} 件",
|
"건너뛴 링크 {0}개": "スキップしたリンク {0} 件",
|
||||||
"건너뜀 (이미 있음): {0}": "スキップ (すでにあります): {0}",
|
"건너뜀 (이미 있음): {0}": "スキップ (すでにあります): {0}",
|
||||||
|
|
|
||||||
|
|
@ -465,6 +465,7 @@
|
||||||
"rsync 양쪽 (⇄)": "rsync 双向 (⇄)",
|
"rsync 양쪽 (⇄)": "rsync 双向 (⇄)",
|
||||||
"rsync 에 고르기": "选为 rsync",
|
"rsync 에 고르기": "选为 rsync",
|
||||||
"rsync 옵션": "rsync 选项",
|
"rsync 옵션": "rsync 选项",
|
||||||
|
"rsync 옵션에서 뺀 것 (--exclude 등) 이지만 --delete-excluded 가 있어 대상에서 지웁니다. 지키려면 옵션에서 --delete-excluded 를 빼세요.": "这些是 rsync 选项中排除的项目 (--exclude 等),但因为有 --delete-excluded,会从目标中删除。若要保留,请从选项中去掉 --delete-excluded。",
|
||||||
"rsync 종료 코드 {0}": "rsync 退出代码 {0}",
|
"rsync 종료 코드 {0}": "rsync 退出代码 {0}",
|
||||||
"settings.json 없음": "缺少 settings.json",
|
"settings.json 없음": "缺少 settings.json",
|
||||||
"stable-diffusion.cpp (Windows · CPU · Vulkan)": "stable-diffusion.cpp (Windows · CPU · Vulkan)",
|
"stable-diffusion.cpp (Windows · CPU · Vulkan)": "stable-diffusion.cpp (Windows · CPU · Vulkan)",
|
||||||
|
|
@ -695,6 +696,7 @@
|
||||||
"개인 키의 암호가 맞지 않습니다": "私钥密码不正确",
|
"개인 키의 암호가 맞지 않습니다": "私钥密码不正确",
|
||||||
"거꾸로": "倒序",
|
"거꾸로": "倒序",
|
||||||
"거꾸로 된 범위: {0}": "范围顺序颠倒: {0}",
|
"거꾸로 된 범위: {0}": "范围顺序颠倒: {0}",
|
||||||
|
"거른 것 {0}개도 지워집니다 (--delete-excluded)": "已排除的 {0} 项也会被删除 (--delete-excluded)",
|
||||||
"건너뛰기": "跳过",
|
"건너뛰기": "跳过",
|
||||||
"건너뛴 링크 {0}개": "跳过的链接 {0} 个",
|
"건너뛴 링크 {0}개": "跳过的链接 {0} 个",
|
||||||
"건너뜀 (이미 있음): {0}": "已跳过(已存在):{0}",
|
"건너뜀 (이미 있음): {0}": "已跳过(已存在):{0}",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/rendering.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:integration_test/integration_test.dart';
|
||||||
|
import 'package:jj_mkvmaker/app/app_controller.dart';
|
||||||
|
import 'package:jj_mkvmaker/app/i18n_controller.dart';
|
||||||
|
import 'package:jj_mkvmaker/app/live_sync.dart';
|
||||||
|
import 'package:jj_mkvmaker/app/settings.dart';
|
||||||
|
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:jj_mkvmaker/ui/monitor_page.dart';
|
||||||
|
import 'package:jj_mkvmaker/ui/theme.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
|
||||||
|
import 'support/temp_dir.dart';
|
||||||
|
|
||||||
|
/// 관리자 3a 조건 (10/20): 실시간 동기화의 지울지 묻는 창 - --delete-excluded 로 지워지는 거른 것을 따로 묶은 모양을
|
||||||
|
/// 크기 412 · 960, 언어 ko · en · ja 로 찍는다. 시험 폴더 안에서만 (확인 창은 [나중에] - 지우지 않음).
|
||||||
|
/// 실행: flutter test integration_test\windows_live_excl_shot_test.dart -d windows (JJ_SHOT_DIR 에 PNG)
|
||||||
|
void main() {
|
||||||
|
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
final out = Platform.environment['JJ_SHOT_DIR'] ?? p.join(Directory.systemTemp.path, 'jj_shots_live_excl');
|
||||||
|
final rsync = p.join(Directory.current.path, 'third_party', 'rsync', 'windows', 'rsync.exe');
|
||||||
|
for (final lang in ['ko', 'en', 'ja']) {
|
||||||
|
for (final (w, h) in [(412, 900), (960, 600)]) {
|
||||||
|
testWidgets('지울지 묻는 창 $lang ${w}x$h', (t) async {
|
||||||
|
if (!File(rsync).existsSync()) return markTestSkipped('앱에 든 rsync 가 없음');
|
||||||
|
t.view.physicalSize = Size(w.toDouble(), h.toDouble());
|
||||||
|
t.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(t.view.reset);
|
||||||
|
await t.runAsync(() => i18n.apply(lang, save: false));
|
||||||
|
addTearDown(() => i18n.apply('ko', save: false));
|
||||||
|
final tmp = testTempDir('jj_liveexcl_shot_');
|
||||||
|
final src = p.join(tmp.path, 's'), dst = p.join(tmp.path, 'd');
|
||||||
|
for (final (dir, rel) in [
|
||||||
|
(src, 'a.txt'),
|
||||||
|
(dst, 'only.txt'),
|
||||||
|
(dst, '오래된 메모.txt'),
|
||||||
|
(dst, 'skip.tmp'),
|
||||||
|
(dst, 'cache/x.tmp'),
|
||||||
|
(dst, 'build/obj/very_long_intermediate_file_name.tmp'),
|
||||||
|
]) {
|
||||||
|
File(p.join(dir, rel))
|
||||||
|
..createSync(recursive: true)
|
||||||
|
..writeAsStringSync(rel);
|
||||||
|
}
|
||||||
|
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
||||||
|
c.settings
|
||||||
|
..rsyncSource = 'custom'
|
||||||
|
..rsyncPath = rsync
|
||||||
|
..rsyncOptions = '-av --exclude=*.tmp --delete-excluded';
|
||||||
|
final pair = LiveSyncPair(src, dst, method: 'rsync', delete: true);
|
||||||
|
c.settings.liveSyncPairs = [pair];
|
||||||
|
final live = LiveSync(c);
|
||||||
|
addTearDown(live.dispose);
|
||||||
|
await t.runAsync(() => live.syncNow(pair));
|
||||||
|
expect(live.toDeleteExcluded[LiveSync.keyOf(pair)], hasLength(3));
|
||||||
|
final navKey = GlobalKey<NavigatorState>();
|
||||||
|
final shotKey = GlobalKey();
|
||||||
|
await t.pumpWidget(RepaintBoundary(
|
||||||
|
key: shotKey,
|
||||||
|
child: MaterialApp(
|
||||||
|
theme: buildTheme(), debugShowCheckedModeBanner: false, navigatorKey: navKey, home: const Scaffold(body: SizedBox.expand())),
|
||||||
|
));
|
||||||
|
unawaited(confirmLiveDeletes(navKey.currentContext!, live, pair));
|
||||||
|
for (var i = 0; i < 8; i++) {
|
||||||
|
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 60)));
|
||||||
|
await t.pump(const Duration(milliseconds: 60));
|
||||||
|
}
|
||||||
|
expect(t.takeException(), isNull, reason: '넘침');
|
||||||
|
final b = shotKey.currentContext!.findRenderObject()! as RenderRepaintBoundary;
|
||||||
|
final img = await t.runAsync(() => b.toImage());
|
||||||
|
final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png));
|
||||||
|
Directory(out).createSync(recursive: true);
|
||||||
|
File(p.join(out, 'live_excl_${lang}_$w.png')).writeAsBytesSync(bytes!.buffer.asUint8List());
|
||||||
|
navKey.currentState!.pop();
|
||||||
|
await t.pump();
|
||||||
|
expect(File(p.join(dst, 'skip.tmp')).existsSync(), isTrue, reason: '[나중에] - 지우지 않음');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -157,11 +157,27 @@ class LiveSync extends ChangeNotifier {
|
||||||
/// 받는 쪽에만 있는 [path] (상대 경로 [r]) 가 지워지는 모양을 [out] 에 "− 경로" 로 (rsync · _mirrorV 와 같게):
|
/// 받는 쪽에만 있는 [path] (상대 경로 [r]) 가 지워지는 모양을 [out] 에 "− 경로" 로 (rsync · _mirrorV 와 같게):
|
||||||
/// 거른 것은 지우지 않고 (--delete-excluded 면 지움), 안에 거른 것이 든 폴더는 남기고 그 안의 거르지 않은 것만.
|
/// 거른 것은 지우지 않고 (--delete-excluded 면 지움), 안에 거른 것이 든 폴더는 남기고 그 안의 거르지 않은 것만.
|
||||||
/// 10/20 (15, 28 의 장면으로 찾음): 예전에는 그런 폴더도 "− 폴더" 한 줄이라 지울지 묻는 창에 폴더가 통째로 보이고,
|
/// 10/20 (15, 28 의 장면으로 찾음): 예전에는 그런 폴더도 "− 폴더" 한 줄이라 지울지 묻는 창에 폴더가 통째로 보이고,
|
||||||
/// 맞춘 뒤에도 (폴더가 남으므로) 맞출 것에 계속 남았다
|
/// 맞춘 뒤에도 (폴더가 남으므로) 맞출 것에 계속 남았다.
|
||||||
static Future<void> _gone(String path, String r, bool isDir, RsyncFilter filter, List<String> out, int limit) async {
|
/// [excluded] 가 있으면 --delete-excluded 때문에 지워지는 것 (거른 것 · 거른 것이 든 폴더) 을 거기에도 (확인 창에서 따로 묶음)
|
||||||
|
static Future<void> _gone(String path, String r, bool isDir, RsyncFilter filter, List<String> out, int limit,
|
||||||
|
[List<String>? excluded]) async {
|
||||||
if (out.length >= limit) return;
|
if (out.length >= limit) return;
|
||||||
if (filter.excluded(r, isDir: isDir) && !filter.deleteExcluded) return;
|
if (filter.excluded(r, isDir: isDir)) {
|
||||||
if (!isDir || filter.isEmpty || filter.deleteExcluded || !await _hasExcluded(path, r, filter)) {
|
if (!filter.deleteExcluded) return;
|
||||||
|
out.add('− $r');
|
||||||
|
excluded?.add(r);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isDir || filter.isEmpty) {
|
||||||
|
out.add('− $r');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (filter.deleteExcluded) {
|
||||||
|
out.add('− $r');
|
||||||
|
if (excluded != null && await _hasExcluded(path, r, filter)) excluded.add(r);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!await _hasExcluded(path, r, filter)) {
|
||||||
out.add('− $r');
|
out.add('− $r');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -174,7 +190,7 @@ class LiveSync extends ChangeNotifier {
|
||||||
for (final e in items) {
|
for (final e in items) {
|
||||||
final name = vBasename(e.path);
|
final name = vBasename(e.path);
|
||||||
if (name.endsWith('.jjsync') || isAppTempName(name)) continue;
|
if (name.endsWith('.jjsync') || isAppTempName(name)) continue;
|
||||||
await _gone(e.path, '$r/$name', e.isDir, filter, out, limit);
|
await _gone(e.path, '$r/$name', e.isDir, filter, out, limit, excluded);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -194,8 +210,10 @@ class LiveSync extends ChangeNotifier {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<List<String>> diff(LiveSyncPair x, {int limit = 500, RsyncFilter filter = RsyncFilter.none}) async {
|
/// [excluded]: 지울 것 가운데 --delete-excluded 때문에 지워지는 것 (거른 것 · 거른 것이 든 폴더) 을 모은다 ([_gone])
|
||||||
if (isDav(x.source) || isDav(x.target)) return _diffV(x, limit: limit, filter: filter);
|
static Future<List<String>> diff(LiveSyncPair x,
|
||||||
|
{int limit = 500, RsyncFilter filter = RsyncFilter.none, List<String>? excluded}) async {
|
||||||
|
if (isDav(x.source) || isDav(x.target)) return _diffV(x, limit: limit, filter: filter, excluded: excluded);
|
||||||
final out = <String>[];
|
final out = <String>[];
|
||||||
Future<void> walk(String src, String dst, String rel) async {
|
Future<void> walk(String src, String dst, String rel) async {
|
||||||
if (out.length >= limit) return;
|
if (out.length >= limit) return;
|
||||||
|
|
@ -233,7 +251,7 @@ class LiveSync extends ChangeNotifier {
|
||||||
await for (final e in Directory(dst).list(followLinks: false).handleError((_) {})) {
|
await for (final e in Directory(dst).list(followLinks: false).handleError((_) {})) {
|
||||||
final name = p.basename(e.path);
|
final name = p.basename(e.path);
|
||||||
if (names.contains(name) || name.endsWith('.jjsync') || isAppTempName(name)) continue;
|
if (names.contains(name) || name.endsWith('.jjsync') || isAppTempName(name)) continue;
|
||||||
await _gone(e.path, rel.isEmpty ? name : '$rel/$name', e is Directory, filter, out, limit);
|
await _gone(e.path, rel.isEmpty ? name : '$rel/$name', e is Directory, filter, out, limit, excluded);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -243,7 +261,8 @@ class LiveSync extends ChangeNotifier {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [diff] 의 WebDAV 판 (한쪽이라도 dav://): 크기가 다르거나, 대상이 원본보다 옛것이면
|
/// [diff] 의 WebDAV 판 (한쪽이라도 dav://): 크기가 다르거나, 대상이 원본보다 옛것이면
|
||||||
static Future<List<String>> _diffV(LiveSyncPair x, {int limit = 500, RsyncFilter filter = RsyncFilter.none}) async {
|
static Future<List<String>> _diffV(LiveSyncPair x,
|
||||||
|
{int limit = 500, RsyncFilter filter = RsyncFilter.none, List<String>? excluded}) async {
|
||||||
final out = <String>[];
|
final out = <String>[];
|
||||||
Future<void> walk(String src, String dst, String rel) async {
|
Future<void> walk(String src, String dst, String rel) async {
|
||||||
if (out.length >= limit) return;
|
if (out.length >= limit) return;
|
||||||
|
|
@ -282,7 +301,7 @@ class LiveSync extends ChangeNotifier {
|
||||||
for (final t in there.entries) {
|
for (final t in there.entries) {
|
||||||
final name = t.key;
|
final name = t.key;
|
||||||
if (names.contains(name) || name.endsWith('.jjsync') || isAppTempName(name)) continue;
|
if (names.contains(name) || name.endsWith('.jjsync') || isAppTempName(name)) continue;
|
||||||
await _gone(t.value.path, rel.isEmpty ? name : '$rel/$name', t.value.isDir, filter, out, limit);
|
await _gone(t.value.path, rel.isEmpty ? name : '$rel/$name', t.value.isDir, filter, out, limit, excluded);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -330,6 +349,10 @@ class LiveSync extends ChangeNotifier {
|
||||||
/// 지우기를 확인하지 않은 쌍: 대상에만 있어 지워질 항목 (원본 기준 상대 경로). 확인 창에 보여 준다 (42)
|
/// 지우기를 확인하지 않은 쌍: 대상에만 있어 지워질 항목 (원본 기준 상대 경로). 확인 창에 보여 준다 (42)
|
||||||
final toDelete = <String, List<String>>{};
|
final toDelete = <String, List<String>>{};
|
||||||
|
|
||||||
|
/// [toDelete] 가운데 --delete-excluded 때문에 지워지는 것 (거른 것 · 거른 것이 든 폴더). 확인 창에서 따로 묶어 보인다
|
||||||
|
/// (관리자 3a 조건 10/20: 사용자가 알고 누르게)
|
||||||
|
final toDeleteExcluded = <String, List<String>>{};
|
||||||
|
|
||||||
/// 원본을 읽지 못했거나 원본이 비어 멈춘 쌍 (68 · 70). 성공하면 지운다. 카드에 빨갛게 · 작업 알림에도
|
/// 원본을 읽지 못했거나 원본이 비어 멈춘 쌍 (68 · 70). 성공하면 지운다. 카드에 빨갛게 · 작업 알림에도
|
||||||
final problems = <String, SourceUnreadableException>{};
|
final problems = <String, SourceUnreadableException>{};
|
||||||
|
|
||||||
|
|
@ -354,6 +377,7 @@ class LiveSync extends ChangeNotifier {
|
||||||
Future<void> decideDelete(LiveSyncPair x, {required bool delete}) async {
|
Future<void> decideDelete(LiveSyncPair x, {required bool delete}) async {
|
||||||
final now = delete ? x.copyWith(deleteConfirmed: true) : x.copyWith(delete: false);
|
final now = delete ? x.copyWith(deleteConfirmed: true) : x.copyWith(delete: false);
|
||||||
toDelete.remove(keyOf(x));
|
toDelete.remove(keyOf(x));
|
||||||
|
toDeleteExcluded.remove(keyOf(x));
|
||||||
await _setPair(x, now);
|
await _setPair(x, now);
|
||||||
await syncNow(now);
|
await syncNow(now);
|
||||||
}
|
}
|
||||||
|
|
@ -455,15 +479,20 @@ class LiveSync extends ChangeNotifier {
|
||||||
try {
|
try {
|
||||||
// 42: 지우기를 아직 확인하지 않았으면, 지울 것이 있을 때 지우지 않고 맞추고 확인을 기다린다 (목록은 [toDelete])
|
// 42: 지우기를 아직 확인하지 않았으면, 지울 것이 있을 때 지우지 않고 맞추고 확인을 기다린다 (목록은 [toDelete])
|
||||||
if (x.delete && !x.deleteConfirmed) {
|
if (x.delete && !x.deleteConfirmed) {
|
||||||
final del = [for (final d in await diff(x, limit: 100000, filter: _filterOf(x))) if (d.startsWith('− ')) d.substring(2)];
|
final excl = <String>[];
|
||||||
|
final del = [
|
||||||
|
for (final d in await diff(x, limit: 100000, filter: _filterOf(x), excluded: excl)) if (d.startsWith('− ')) d.substring(2)
|
||||||
|
];
|
||||||
if (del.isEmpty) {
|
if (del.isEmpty) {
|
||||||
await _setPair(x, x.copyWith(deleteConfirmed: true));
|
await _setPair(x, x.copyWith(deleteConfirmed: true));
|
||||||
} else {
|
} else {
|
||||||
toDelete[k] = del;
|
toDelete[k] = del;
|
||||||
|
toDeleteExcluded[k] = excl;
|
||||||
x = x.copyWith(delete: false);
|
x = x.copyWith(delete: false);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
toDelete.remove(k);
|
toDelete.remove(k);
|
||||||
|
toDeleteExcluded.remove(k);
|
||||||
}
|
}
|
||||||
// 지우기 포함이면 원본을 확실히 읽을 수 있을 때만 (rsync --delete · robocopy /PURGE 도). 못 읽으면 멈추고 알린다
|
// 지우기 포함이면 원본을 확실히 읽을 수 있을 때만 (rsync --delete · robocopy /PURGE 도). 못 읽으면 멈추고 알린다
|
||||||
final allowEmpty = _allowEmpty.remove(k);
|
final allowEmpty = _allowEmpty.remove(k);
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,10 @@ class MonitorPage extends StatelessWidget {
|
||||||
Future<void> confirmLiveDeletes(BuildContext context, LiveSync live, LiveSyncPair x) async {
|
Future<void> confirmLiveDeletes(BuildContext context, LiveSync live, LiveSyncPair x) async {
|
||||||
final list = live.toDelete[LiveSync.keyOf(x)] ?? const <String>[];
|
final list = live.toDelete[LiveSync.keyOf(x)] ?? const <String>[];
|
||||||
if (list.isEmpty) return;
|
if (list.isEmpty) return;
|
||||||
|
// 관리자 3a 조건 (10/20): --delete-excluded 때문에 지워지는 것 (거른 것 · 거른 것이 든 폴더) 은 따로 묶어 알고 누르게
|
||||||
|
final excl = (live.toDeleteExcluded[LiveSync.keyOf(x)] ?? const <String>[]).toSet();
|
||||||
|
final plain = [for (final n in list) if (!excl.contains(n)) n];
|
||||||
|
final filtered = [for (final n in list) if (excl.contains(n)) n];
|
||||||
final pick = await showDialog<bool>(
|
final pick = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
|
|
@ -93,8 +97,18 @@ Future<void> confirmLiveDeletes(BuildContext context, LiveSync live, LiveSyncPai
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ListView(children: [
|
child: ListView(children: [
|
||||||
for (final n in list.take(500)) Text(n, style: const TextStyle(fontSize: 12, fontFamily: 'Consolas')),
|
for (final n in plain.take(500)) Text(n, style: const TextStyle(fontSize: 12, fontFamily: 'Consolas')),
|
||||||
if (list.length > 500) Text(trf('… 외 {0}개', [list.length - 500])),
|
if (plain.length > 500) Text(trf('… 외 {0}개', [plain.length - 500])),
|
||||||
|
if (filtered.isNotEmpty) ...[
|
||||||
|
if (plain.isNotEmpty) const SizedBox(height: 10),
|
||||||
|
Text(trf('거른 것 {0}개도 지워집니다 (--delete-excluded)', [filtered.length]),
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w600, color: JjColors.danger)),
|
||||||
|
Text(tr('rsync 옵션에서 뺀 것 (--exclude 등) 이지만 --delete-excluded 가 있어 대상에서 지웁니다. 지키려면 옵션에서 --delete-excluded 를 빼세요.'),
|
||||||
|
style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
for (final n in filtered.take(500)) Text(n, style: const TextStyle(fontSize: 12, fontFamily: 'Consolas', color: JjColors.danger)),
|
||||||
|
if (filtered.length > 500) Text(trf('… 외 {0}개', [filtered.length - 500])),
|
||||||
|
],
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,17 @@
|
||||||
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:jj_mkvmaker/app/app_controller.dart';
|
import 'package:jj_mkvmaker/app/app_controller.dart';
|
||||||
import 'package:jj_mkvmaker/app/live_sync.dart';
|
import 'package:jj_mkvmaker/app/live_sync.dart';
|
||||||
import 'package:jj_mkvmaker/app/settings.dart';
|
import 'package:jj_mkvmaker/app/settings.dart';
|
||||||
|
import 'package:jj_mkvmaker/core/rsync_filter.dart';
|
||||||
import 'package:jj_mkvmaker/core/sync_preview.dart';
|
import 'package:jj_mkvmaker/core/sync_preview.dart';
|
||||||
import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
|
import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
|
||||||
import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart';
|
import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart';
|
||||||
import 'package:jj_mkvmaker/services/platform_services.dart';
|
import 'package:jj_mkvmaker/services/platform_services.dart';
|
||||||
|
import 'package:jj_mkvmaker/ui/monitor_page.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
|
|
||||||
import 'support/rsync_scenes.dart';
|
import 'support/rsync_scenes.dart';
|
||||||
|
|
@ -49,6 +53,15 @@ void main() {
|
||||||
for (final a in asked) {
|
for (final a in asked) {
|
||||||
if (!gone.contains(a)) why.add('rsync 는 지우지 않는데 물음: $a');
|
if (!gone.contains(a)) why.add('rsync 는 지우지 않는데 물음: $a');
|
||||||
}
|
}
|
||||||
|
// 관리자 3a 조건 (10/20): --delete-excluded 때문에 지워지는 것 (거른 것 · 거른 것이 든 폴더) 은 확인 창에서 따로
|
||||||
|
final excl = live.toDeleteExcluded[k] ?? const <String>[];
|
||||||
|
final wantExcl = {
|
||||||
|
'-a --delete --exclude=*.bak --delete-excluded': {'onlydst'},
|
||||||
|
'-a --delete --exclude=*.log --delete-excluded': {'keep.log', 'old', 'old2'},
|
||||||
|
}[opts] ?? const <String>{};
|
||||||
|
if (!RsyncFilter.parse(opts).deleteExcluded && excl.isNotEmpty) why.add('--delete-excluded 가 없는데 따로 묶음: $excl');
|
||||||
|
if (excl.toSet().difference(asked.toSet()).isNotEmpty) why.add('따로 묶은 것이 지울 목록에 없음: $excl');
|
||||||
|
if (excl.toSet().length != wantExcl.length || !excl.toSet().containsAll(wantExcl)) why.add('따로 묶은 것 $excl ≠ $wantExcl');
|
||||||
if (asked.isNotEmpty) await live.decideDelete(c.settings.liveSyncPairs.single, delete: true);
|
if (asked.isNotEmpty) await live.decideDelete(c.settings.liveSyncPairs.single, delete: true);
|
||||||
why.addAll(treeDiff(w, treeOf(got)));
|
why.addAll(treeDiff(w, treeOf(got)));
|
||||||
if ((live.pending[k] ?? const []).isNotEmpty) why.add('맞춘 뒤에도 맞출 것: ${live.pending[k]}');
|
if ((live.pending[k] ?? const []).isNotEmpty) why.add('맞춘 뒤에도 맞출 것: ${live.pending[k]}');
|
||||||
|
|
@ -57,4 +70,48 @@ void main() {
|
||||||
}
|
}
|
||||||
expect(bad, isEmpty, reason: bad.join('\n'));
|
expect(bad, isEmpty, reason: bad.join('\n'));
|
||||||
}, timeout: const Timeout(Duration(minutes: 5)));
|
}, timeout: const Timeout(Duration(minutes: 5)));
|
||||||
|
|
||||||
|
testWidgets('관리자 3a 조건 (10/20): 지울지 묻는 창 - --delete-excluded 로 지워지는 거른 것은 따로 묶어 빨갛게', (tester) async {
|
||||||
|
final rsync = sceneRsync;
|
||||||
|
if (rsync == null) return markTestSkipped('앱에 든 rsync 가 없음');
|
||||||
|
final tmp = Directory.systemTemp.createTempSync('jj_liveexcl_');
|
||||||
|
addTearDown(() => tmp.deleteSync(recursive: true));
|
||||||
|
final src = p.join(tmp.path, 's'), dst = p.join(tmp.path, 'd');
|
||||||
|
for (final (dir, rel) in [(src, 'a.txt'), (dst, 'only.txt'), (dst, 'skip.tmp'), (dst, 'cache/x.tmp')]) {
|
||||||
|
File(p.join(dir, rel))
|
||||||
|
..createSync(recursive: true)
|
||||||
|
..writeAsStringSync(rel);
|
||||||
|
}
|
||||||
|
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
||||||
|
c.settings
|
||||||
|
..rsyncSource = 'custom'
|
||||||
|
..rsyncPath = rsync
|
||||||
|
..rsyncOptions = '-av --exclude=*.tmp --delete-excluded';
|
||||||
|
final pair = LiveSyncPair(src, dst, method: 'rsync', delete: true);
|
||||||
|
c.settings.liveSyncPairs = [pair];
|
||||||
|
final live = LiveSync(c);
|
||||||
|
addTearDown(live.dispose);
|
||||||
|
await tester.runAsync(() => live.syncNow(pair));
|
||||||
|
final k = LiveSync.keyOf(pair);
|
||||||
|
expect(live.toDelete[k], unorderedEquals(['only.txt', 'skip.tmp', 'cache']));
|
||||||
|
expect(live.toDeleteExcluded[k], unorderedEquals(['skip.tmp', 'cache']), reason: 'cache 는 거른 것이 든 폴더');
|
||||||
|
expect(File(p.join(dst, 'skip.tmp')).existsSync(), isTrue, reason: '확인 전에는 지우지 않음');
|
||||||
|
late BuildContext ctx;
|
||||||
|
await tester.pumpWidget(MaterialApp(home: Builder(builder: (x) {
|
||||||
|
ctx = x;
|
||||||
|
return const SizedBox();
|
||||||
|
})));
|
||||||
|
unawaited(confirmLiveDeletes(ctx, live, pair));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 300));
|
||||||
|
expect(find.text('대상에서 3개를 지울까요?'), findsOneWidget);
|
||||||
|
expect(find.text('거른 것 2개도 지워집니다 (--delete-excluded)'), findsOneWidget);
|
||||||
|
// 순서: 그냥 지울 것 → 묶음 제목 → 거른 것
|
||||||
|
final y = {for (final n in ['only.txt', '거른 것 2개도 지워집니다 (--delete-excluded)', 'skip.tmp', 'cache']) n: tester.getTopLeft(find.text(n)).dy};
|
||||||
|
expect(y['only.txt']! < y['거른 것 2개도 지워집니다 (--delete-excluded)']!, isTrue);
|
||||||
|
expect(y['거른 것 2개도 지워집니다 (--delete-excluded)']! < y['skip.tmp']!, isTrue);
|
||||||
|
expect(y['거른 것 2개도 지워집니다 (--delete-excluded)']! < y['cache']!, isTrue);
|
||||||
|
await tester.tap(find.text('나중에'));
|
||||||
|
await tester.pump();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue