실시간 동기화: 확인한 쌍도 rsync 옵션이 바뀌어 지울 것이 늘면 다시 묻기 (검토 대기)

관리자 3a 판단 (10/20, 데이터 쪽이라 안전한 쪽으로): 로컬 · 원격 쌍 모두.
- 쌍에 확인할 때의 rsync 옵션을 기억 (LiveSyncPair.confirmedOptions, 설정 파일에 저장 - 틀린 값은 없는 것으로).
- 맞추기 전에 지금 옵션이 기억한 것과 다르면, 두 옵션으로 지울 것을 견주어 늘었으면 (--delete-excluded · --exclude ·
  --filter 를 바꿔 새로 지워질 것이 생김) 지우지 않고 맞춘 뒤 다시 묻는다 (42 와 같은 창, 주황 "옵션이 바뀌어 지울 것이
  늘었습니다" 줄). 늘지 않았으면 묻지 않고 지금 옵션을 기억.
- 이 값이 생기기 전에 확인한 쌍은 예전 동작 (--delete-excluded 를 따르지 않음) 과 견준다 - 업데이트 뒤 --delete-excluded 로
  새로 지워질 것이 있으면 묻고, 없으면 기억만 (처음 한 번 설정을 저장).
- 확인 창 높이를 화면 높이에 따라 (360 ~ 520) - 412 세로에서 거른 것 묶음이 잘려 보이던 것 (캡처로 찾음).
시험: live_sync_scene_test (로컬, 실제 rsync - 늘지 않는 바꿈 · --delete-excluded 더함 · 거르기 뺌 · 예전 쌍 · 설정 저장),
webdav_test (원격 쌍). LiveSync 를 쓰는 시험 14개 파일 148개 통과. 캡처 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
RyuJongJin 2026-10-20 12:26:08 +09:00
parent 44a6c4e6b2
commit a8bc585b1a
9 changed files with 177 additions and 8 deletions

View File

@ -2427,6 +2427,7 @@
"지우기가 들어 있는 작업입니다": "This task deletes files", "지우기가 들어 있는 작업입니다": "This task deletes files",
"지우기가 들어간 실행이라, 비교하지 못하면 실행할 수 없습니다. 원본 · 대상을 확인한 뒤 [다시 비교] 를 누르세요.": "This run deletes files, so it can't run without a comparison. Check the source and target, then press [Compare again].", "지우기가 들어간 실행이라, 비교하지 못하면 실행할 수 없습니다. 원본 · 대상을 확인한 뒤 [다시 비교] 를 누르세요.": "This run deletes files, so it can't run without a comparison. Check the source and target, then press [Compare again].",
"지우기가 들어간 실행이라, 지울 목록이 나온 뒤에 실행할 수 있습니다.": "This run deletes files, so you can run it after the list of deletions appears.", "지우기가 들어간 실행이라, 지울 목록이 나온 뒤에 실행할 수 있습니다.": "This run deletes files, so you can run it after the list of deletions appears.",
"지우기를 확인한 뒤 rsync 옵션이 바뀌어 지울 것이 늘었습니다. 다시 확인해 주세요.": "The rsync options changed after you confirmed deleting, and more items would now be deleted. Please confirm again.",
"지우는 폴더 안에 목록에서 숨긴 항목 {0}개가 있습니다 - 함께 지워집니다": "The folders being deleted contain {0} items hidden from the list - they will be deleted too", "지우는 폴더 안에 목록에서 숨긴 항목 {0}개가 있습니다 - 함께 지워집니다": "The folders being deleted contain {0} items hidden from the list - they will be deleted too",
"지우면 휴지통으로": "Delete to the Recycle Bin", "지우면 휴지통으로": "Delete to the Recycle Bin",
"지우지 못한 파일: {0}": "Couldn't delete: {0}", "지우지 못한 파일: {0}": "Couldn't delete: {0}",

View File

@ -2427,6 +2427,7 @@
"지우기가 들어 있는 작업입니다": "削除を含む作業です", "지우기가 들어 있는 작업입니다": "削除を含む作業です",
"지우기가 들어간 실행이라, 비교하지 못하면 실행할 수 없습니다. 원본 · 대상을 확인한 뒤 [다시 비교] 를 누르세요.": "削除を含む実行のため、比較できないと実行できません。元・対象を確認してから [もう一度比較] を押してください。", "지우기가 들어간 실행이라, 비교하지 못하면 실행할 수 없습니다. 원본 · 대상을 확인한 뒤 [다시 비교] 를 누르세요.": "削除を含む実行のため、比較できないと実行できません。元・対象を確認してから [もう一度比較] を押してください。",
"지우기가 들어간 실행이라, 지울 목록이 나온 뒤에 실행할 수 있습니다.": "削除を含む実行のため、削除一覧が表示されてから実行できます。", "지우기가 들어간 실행이라, 지울 목록이 나온 뒤에 실행할 수 있습니다.": "削除を含む実行のため、削除一覧が表示されてから実行できます。",
"지우기를 확인한 뒤 rsync 옵션이 바뀌어 지울 것이 늘었습니다. 다시 확인해 주세요.": "削除を確認した後に rsync オプションが変わり、削除するものが増えました。もう一度確認してください。",
"지우는 폴더 안에 목록에서 숨긴 항목 {0}개가 있습니다 - 함께 지워집니다": "削除するフォルダーの中にリストから隠した項目が {0} 件あります - 一緒に削除されます", "지우는 폴더 안에 목록에서 숨긴 항목 {0}개가 있습니다 - 함께 지워집니다": "削除するフォルダーの中にリストから隠した項目が {0} 件あります - 一緒に削除されます",
"지우면 휴지통으로": "削除するとごみ箱へ", "지우면 휴지통으로": "削除するとごみ箱へ",
"지우지 못한 파일: {0}": "削除できなかったファイル: {0}", "지우지 못한 파일: {0}": "削除できなかったファイル: {0}",

View File

@ -2427,6 +2427,7 @@
"지우기가 들어 있는 작업입니다": "此任务包含删除", "지우기가 들어 있는 작업입니다": "此任务包含删除",
"지우기가 들어간 실행이라, 비교하지 못하면 실행할 수 없습니다. 원본 · 대상을 확인한 뒤 [다시 비교] 를 누르세요.": "此操作包含删除,无法比较时不能执行。请检查源和目标后按 [重新比较]。", "지우기가 들어간 실행이라, 비교하지 못하면 실행할 수 없습니다. 원본 · 대상을 확인한 뒤 [다시 비교] 를 누르세요.": "此操作包含删除,无法比较时不能执行。请检查源和目标后按 [重新比较]。",
"지우기가 들어간 실행이라, 지울 목록이 나온 뒤에 실행할 수 있습니다.": "此操作包含删除,需在显示删除列表后才能执行。", "지우기가 들어간 실행이라, 지울 목록이 나온 뒤에 실행할 수 있습니다.": "此操作包含删除,需在显示删除列表后才能执行。",
"지우기를 확인한 뒤 rsync 옵션이 바뀌어 지울 것이 늘었습니다. 다시 확인해 주세요.": "确认删除后 rsync 选项已更改,要删除的项目增加了。请再次确认。",
"지우는 폴더 안에 목록에서 숨긴 항목 {0}개가 있습니다 - 함께 지워집니다": "要删除的文件夹中有 {0} 个从列表中隐藏的项 - 将一并删除", "지우는 폴더 안에 목록에서 숨긴 항목 {0}개가 있습니다 - 함께 지워집니다": "要删除的文件夹中有 {0} 个从列表中隐藏的项 - 将一并删除",
"지우면 휴지통으로": "删除时移到回收站", "지우면 휴지통으로": "删除时移到回收站",
"지우지 못한 파일: {0}": "无法删除的文件: {0}", "지우지 못한 파일: {0}": "无法删除的文件: {0}",

View File

@ -54,12 +54,14 @@ void main() {
..rsyncSource = 'custom' ..rsyncSource = 'custom'
..rsyncPath = rsync ..rsyncPath = rsync
..rsyncOptions = '-av --exclude=*.tmp --delete-excluded'; ..rsyncOptions = '-av --exclude=*.tmp --delete-excluded';
final pair = LiveSyncPair(src, dst, method: 'rsync', delete: true); // 이미 --exclude 만으로 확인한 쌍에 --delete-excluded 를 더함 → 다시 묻는 창 (주황 이유 줄 · 거른 것 묶음)
final pair = LiveSyncPair(src, dst, method: 'rsync', delete: true, deleteConfirmed: true, confirmedOptions: '-av --exclude=*.tmp');
c.settings.liveSyncPairs = [pair]; c.settings.liveSyncPairs = [pair];
final live = LiveSync(c); final live = LiveSync(c);
addTearDown(live.dispose); addTearDown(live.dispose);
await t.runAsync(() => live.syncNow(pair)); await t.runAsync(() => live.syncNow(pair));
expect(live.toDeleteExcluded[LiveSync.keyOf(pair)], hasLength(3)); expect(live.toDeleteExcluded[LiveSync.keyOf(pair)], hasLength(3));
expect(live.reasked, contains(LiveSync.keyOf(pair)));
final navKey = GlobalKey<NavigatorState>(); final navKey = GlobalKey<NavigatorState>();
final shotKey = GlobalKey(); final shotKey = GlobalKey();
await t.pumpWidget(RepaintBoundary( await t.pumpWidget(RepaintBoundary(

View File

@ -154,6 +154,30 @@ class LiveSync extends ChangeNotifier {
/// 로컬 rsync 쌍은 rsync 가 직접 따르므로 대기 목록 (diff) 에도 같이 /// 로컬 rsync 쌍은 rsync 가 직접 따르므로 대기 목록 (diff) 에도 같이
RsyncFilter _filterOf(LiveSyncPair x) => x.method == 'rsync' ? RsyncFilter.parse(c.settings.rsyncOptions) : RsyncFilter.none; RsyncFilter _filterOf(LiveSyncPair x) => x.method == 'rsync' ? RsyncFilter.parse(c.settings.rsyncOptions) : RsyncFilter.none;
/// 지울 것에 영향을 주는 옵션 (rsync 쌍만 - 거르기 · --delete-excluded). 확인할 때 쌍에 기억한다 ([LiveSyncPair.confirmedOptions])
String _optionsOf(LiveSyncPair x) => x.method == 'rsync' ? c.settings.rsyncOptions : '';
/// 관리자 3a 판단 (10/20): 확인한 쌍이라도 옵션이 바뀌어 지울 것이 늘었으면 다시 묻는다 (로컬 · 원격 쌍 모두).
/// 늘지 않았으면 (줄거나 같음) 묻지 않고 지금 옵션을 기억한다. 예전에 확인한 쌍 (기억한 옵션 없음) 은 예전 동작
/// (--delete-excluded 를 따르지 않음) 과 견준다 - 업데이트 뒤 --delete-excluded 로 새로 지워질 것이 있으면 묻는다
Future<bool> _deletesGrew(LiveSyncPair x) async {
final old = x.confirmedOptions;
final now = _filterOf(x);
final before = old == null
? RsyncFilter(now.rules, unsupported: now.unsupported)
: x.method == 'rsync'
? RsyncFilter.parse(old)
: RsyncFilter.none;
List<String> gone(List<String> d) => [for (final s in d) if (s.startsWith('− ')) s.substring(2)];
final a = gone(await diff(x, limit: 100000, filter: now));
if (a.isEmpty) return false;
final b = gone(await diff(x, limit: 100000, filter: before));
return a.any((d) => !b.any((e) => d == e || d.startsWith('$e/')));
}
/// 확인한 뒤 옵션이 바뀌어 다시 묻는 쌍 (확인 창에 그 이유를 보인다)
final reasked = <String>{};
/// 받는 쪽에만 있는 [path] (상대 경로 [r]) 가 지워지는 모양을 [out] 에 "− 경로" 로 (rsync · _mirrorV 와 같게): /// 받는 쪽에만 있는 [path] (상대 경로 [r]) 가 지워지는 모양을 [out] 에 "− 경로" 로 (rsync · _mirrorV 와 같게):
/// 거른 것은 지우지 않고 (--delete-excluded 면 지움), 안에 거른 것이 든 폴더는 남기고 그 안의 거르지 않은 것만. /// 거른 것은 지우지 않고 (--delete-excluded 면 지움), 안에 거른 것이 든 폴더는 남기고 그 안의 거르지 않은 것만.
/// 10/20 (15, 28 의 장면으로 찾음): 예전에는 그런 폴더도 "− 폴더" 한 줄이라 지울지 묻는 창에 폴더가 통째로 보이고, /// 10/20 (15, 28 의 장면으로 찾음): 예전에는 그런 폴더도 "− 폴더" 한 줄이라 지울지 묻는 창에 폴더가 통째로 보이고,
@ -375,9 +399,10 @@ class LiveSync extends ChangeNotifier {
/// 지울 목록을 보고 결정: [delete] true = 지우기 포함으로 맞추기 (확인함), false = 지우기 끄기. 그 뒤 바로 맞춘다 /// 지울 목록을 보고 결정: [delete] true = 지우기 포함으로 맞추기 (확인함), false = 지우기 끄기. 그 뒤 바로 맞춘다
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, confirmedOptions: _optionsOf(x)) : x.copyWith(delete: false);
toDelete.remove(keyOf(x)); toDelete.remove(keyOf(x));
toDeleteExcluded.remove(keyOf(x)); toDeleteExcluded.remove(keyOf(x));
reasked.remove(keyOf(x));
await _setPair(x, now); await _setPair(x, now);
await syncNow(now); await syncNow(now);
} }
@ -478,13 +503,27 @@ class LiveSync extends ChangeNotifier {
final asked = x; final asked = x;
try { try {
// 42: 지우기를 아직 확인하지 않았으면, 지울 것이 있을 때 지우지 않고 맞추고 확인을 기다린다 (목록은 [toDelete]) // 42: 지우기를 아직 확인하지 않았으면, 지울 것이 있을 때 지우지 않고 맞추고 확인을 기다린다 (목록은 [toDelete])
if (x.delete && !x.deleteConfirmed) { var ask = x.delete && !x.deleteConfirmed;
if (x.delete && x.deleteConfirmed && x.confirmedOptions != _optionsOf(x)) {
if (await _deletesGrew(x)) {
ask = true;
reasked.add(k);
} else {
final now = x.copyWith(confirmedOptions: _optionsOf(x));
await _setPair(x, now);
x = now;
}
}
if (ask) {
final excl = <String>[]; final excl = <String>[];
final del = [ final del = [
for (final d in await diff(x, limit: 100000, filter: _filterOf(x), excluded: excl)) if (d.startsWith('− ')) d.substring(2) 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)); final now = x.copyWith(deleteConfirmed: true, confirmedOptions: _optionsOf(x));
await _setPair(x, now);
x = now;
reasked.remove(k);
} else { } else {
toDelete[k] = del; toDelete[k] = del;
toDeleteExcluded[k] = excl; toDeleteExcluded[k] = excl;
@ -493,6 +532,7 @@ class LiveSync extends ChangeNotifier {
} else { } else {
toDelete.remove(k); toDelete.remove(k);
toDeleteExcluded.remove(k); toDeleteExcluded.remove(k);
reasked.remove(k);
} }
// 지우기 포함이면 원본을 확실히 읽을 수 있을 때만 (rsync --delete · robocopy /PURGE 도). 못 읽으면 멈추고 알린다 // 지우기 포함이면 원본을 확실히 읽을 수 있을 때만 (rsync --delete · robocopy /PURGE 도). 못 읽으면 멈추고 알린다
final allowEmpty = _allowEmpty.remove(k); final allowEmpty = _allowEmpty.remove(k);

View File

@ -79,21 +79,28 @@ class LiveSyncPair {
/// "지우기" 를 사용자가 지울 목록을 보고 확인했는지. 확인 전에는 지울 것이 있으면 지우지 않고 맞추며 확인을 기다린다 (42). /// "지우기" 를 사용자가 지울 목록을 보고 확인했는지. 확인 전에는 지울 것이 있으면 지우지 않고 맞추며 확인을 기다린다 (42).
/// 이 값이 생기기 전부터 돌던 쌍은 확인한 것으로 본다. /// 이 값이 생기기 전부터 돌던 쌍은 확인한 것으로 본다.
final bool deleteConfirmed; final bool deleteConfirmed;
/// 지우기를 확인할 때의 rsync 옵션 (rsync 쌍만, 그 밖은 ''). 확인한 뒤 옵션이 바뀌어 지울 것이 늘면 다시 묻는다
/// (관리자 3a 판단 10/20 - --delete-excluded · --exclude · --filter 등). null = 이 값이 생기기 전에 확인한 쌍
final String? confirmedOptions;
const LiveSyncPair(this.source, this.target, const LiveSyncPair(this.source, this.target,
{this.method = 'builtin', {this.method = 'builtin',
this.delete = false, this.delete = false,
this.enabled = true, this.enabled = true,
this.schedule = const [], this.schedule = const [],
this.deleteConfirmed = false}); this.deleteConfirmed = false,
this.confirmedOptions});
/// [delete] 를 새로 켜면 확인은 다시 받는다 /// [delete] 를 새로 켜면 확인은 다시 받는다
LiveSyncPair copyWith({String? method, bool? delete, bool? enabled, List<String>? schedule, bool? deleteConfirmed}) => LiveSyncPair copyWith(
{String? method, bool? delete, bool? enabled, List<String>? schedule, bool? deleteConfirmed, String? confirmedOptions}) =>
LiveSyncPair(source, target, LiveSyncPair(source, target,
method: method ?? this.method, method: method ?? this.method,
delete: delete ?? this.delete, delete: delete ?? this.delete,
enabled: enabled ?? this.enabled, enabled: enabled ?? this.enabled,
schedule: schedule ?? this.schedule, schedule: schedule ?? this.schedule,
deleteConfirmed: deleteConfirmed ?? (delete == true && !this.delete ? false : this.deleteConfirmed)); deleteConfirmed: deleteConfirmed ?? (delete == true && !this.delete ? false : this.deleteConfirmed),
confirmedOptions: confirmedOptions ?? this.confirmedOptions);
Map<String, Object?> toJson() => { Map<String, Object?> toJson() => {
'source': source, 'source': source,
@ -103,6 +110,7 @@ class LiveSyncPair {
'enabled': enabled, 'enabled': enabled,
'schedule': schedule, 'schedule': schedule,
'deleteConfirmed': deleteConfirmed, 'deleteConfirmed': deleteConfirmed,
if (confirmedOptions != null) 'confirmedOptions': confirmedOptions,
}; };
factory LiveSyncPair.fromJson(Map<Object?, Object?> j) => LiveSyncPair( factory LiveSyncPair.fromJson(Map<Object?, Object?> j) => LiveSyncPair(
@ -114,6 +122,7 @@ class LiveSyncPair {
schedule: [for (final x in (j['schedule'] as List?) ?? const []) '$x'], schedule: [for (final x in (j['schedule'] as List?) ?? const []) '$x'],
// 이 값이 없는 예전 쌍은 이미 지우기 포함으로 돌던 것 → 확인한 것으로 // 이 값이 없는 예전 쌍은 이미 지우기 포함으로 돌던 것 → 확인한 것으로
deleteConfirmed: j['deleteConfirmed'] as bool? ?? true, deleteConfirmed: j['deleteConfirmed'] as bool? ?? true,
confirmedOptions: j['confirmedOptions'] is String ? j['confirmedOptions'] as String : null,
); );
} }

View File

@ -90,8 +90,15 @@ Future<void> confirmLiveDeletes(BuildContext context, LiveSync live, LiveSyncPai
title: Text(trf('대상에서 {0}개를 지울까요?', [list.length])), title: Text(trf('대상에서 {0}개를 지울까요?', [list.length])),
content: SizedBox( content: SizedBox(
width: 560, width: 560,
height: 360, // 세로 폰에서도 목록 (특히 거른 것 묶음) 이 더 보이게 화면 높이를 따라 (360 ~ 520)
height: (MediaQuery.sizeOf(ctx).height * 0.55).clamp(360.0, 520.0),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
// 관리자 3a 판단 (10/20): 확인한 쌍인데 옵션이 바뀌어 지울 것이 늘어 다시 묻는 것
if (live.reasked.contains(LiveSync.keyOf(x))) ...[
Text(tr('지우기를 확인한 뒤 rsync 옵션이 바뀌어 지울 것이 늘었습니다. 다시 확인해 주세요.'),
style: const TextStyle(fontWeight: FontWeight.w600, color: Colors.orangeAccent)),
const SizedBox(height: 6),
],
Text(trf('"{0}" 에는 없고 대상 "{1}" 에만 있는 항목입니다. 지우기 포함으로 맞추면 대상에서 지워집니다 (되돌릴 수 없음).', Text(trf('"{0}" 에는 없고 대상 "{1}" 에만 있는 항목입니다. 지우기 포함으로 맞추면 대상에서 지워집니다 (되돌릴 수 없음).',
[vDisplay(x.source), vDisplay(x.target)])), [vDisplay(x.source), vDisplay(x.target)])),
const SizedBox(height: 8), const SizedBox(height: 8),

View File

@ -114,4 +114,83 @@ void main() {
await tester.tap(find.text('나중에')); await tester.tap(find.text('나중에'));
await tester.pump(); await tester.pump();
}); });
test('관리자 3a 판단 (10/20): 확인한 쌍도 rsync 옵션이 바뀌어 지울 것이 늘면 다시 묻고, 늘지 않으면 묻지 않고 기억', () async {
final rsync = sceneRsync;
if (rsync == null) return markTestSkipped('앱에 든 rsync 가 없음');
final tmp = Directory.systemTemp.createTempSync('jj_reask_');
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.tmp'), (dst, 'keep/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';
// 이미 이 옵션으로 확인한 쌍
c.settings.liveSyncPairs = [
LiveSyncPair(src, dst, method: 'rsync', delete: true, deleteConfirmed: true, confirmedOptions: '-av --exclude=*.tmp'),
];
final live = LiveSync(c);
addTearDown(live.dispose);
LiveSyncPair pair() => c.settings.liveSyncPairs.single;
final k = LiveSync.keyOf(pair());
await live.syncNow(pair());
expect(live.toDelete[k], isNull);
expect(File(p.join(dst, 'a.txt')).existsSync(), isTrue);
// ① 지울 것이 늘지 않는 바꿈 (-h 더하기) → 묻지 않고 지금 옵션을 기억
c.settings.rsyncOptions = '-avh --exclude=*.tmp';
await live.syncNow(pair());
expect(live.toDelete[k], isNull);
expect(pair().confirmedOptions, '-avh --exclude=*.tmp');
// ② --delete-excluded 를 더함 → 거른 것이 새로 지워지므로 다시 묻고, 확인 전에는 지우지 않음
c.settings.rsyncOptions = '-avh --exclude=*.tmp --delete-excluded';
await live.syncNow(pair());
expect(live.toDelete[k], unorderedEquals(['only.tmp', 'keep']));
expect(live.reasked, contains(k));
expect(live.status[k]!.$2, contains('확인 필요'));
expect(File(p.join(dst, 'only.tmp')).existsSync(), isTrue, reason: '확인 전에는 지우지 않음');
expect(pair().deleteConfirmed, isTrue, reason: '쌍의 확인은 그대로 - 바뀐 옵션만 다시 묻는다');
// 확인하면 지우고 그 옵션을 기억
await live.decideDelete(pair(), delete: true);
expect(File(p.join(dst, 'only.tmp')).existsSync(), isFalse);
expect(Directory(p.join(dst, 'keep')).existsSync(), isFalse);
expect(pair().confirmedOptions, '-avh --exclude=*.tmp --delete-excluded');
expect(live.reasked, isNot(contains(k)));
// ③ 거르기를 빼면 (--exclude 를 지움) 지울 것은 줄거나 같다 → 묻지 않음
c.settings.rsyncOptions = '-avh';
await live.syncNow(pair());
expect(live.toDelete[k], isNull);
expect(pair().confirmedOptions, '-avh');
// ④ 예전에 확인한 쌍 (기억한 옵션 없음): --delete-excluded 로 새로 지워질 것이 있으면 묻는다 · 없으면 기억만
File(p.join(dst, 'new.tmp')).writeAsStringSync('n');
c.settings
..rsyncOptions = '-av --exclude=*.tmp --delete-excluded'
..liveSyncPairs = [LiveSyncPair.fromJson({'source': src, 'target': dst, 'method': 'rsync', 'delete': true})];
expect(pair().confirmedOptions, isNull);
await live.syncNow(pair());
expect(live.toDelete[k], ['new.tmp']);
expect(File(p.join(dst, 'new.tmp')).existsSync(), isTrue);
c.settings
..rsyncOptions = '-av --exclude=*.tmp'
..liveSyncPairs = [LiveSyncPair.fromJson({'source': src, 'target': dst, 'method': 'rsync', 'delete': true})];
await live.syncNow(pair());
expect(live.toDelete[k], isNull);
expect(pair().confirmedOptions, '-av --exclude=*.tmp');
expect(File(p.join(dst, 'new.tmp')).existsSync(), isTrue, reason: '거른 것 - 지우지 않음');
// 설정 파일: 기억한 옵션이 저장 · 읽기에서 남는다
final j = pair().toJson();
expect(j['confirmedOptions'], '-av --exclude=*.tmp');
expect(LiveSyncPair.fromJson(j).confirmedOptions, '-av --exclude=*.tmp');
expect(LiveSyncPair.fromJson({...j, 'confirmedOptions': 3}).confirmedOptions, isNull, reason: '틀린 값은 없는 것으로');
});
} }

View File

@ -152,6 +152,8 @@ void main() {
lf('live/sub/b.txt', 'bb'); lf('live/sub/b.txt', 'bb');
rf('mirror/old.txt', 'o'); rf('mirror/old.txt', 'o');
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
// 예전에 확인한 쌍은 처음 맞출 때 지금 옵션을 기억하며 설정을 저장한다 (서버 목록도 설정에서 다시 읽음) - 시험 서버를 설정에도
c.settings.webdavServers = [DavServer(id: id, name: '집 NAS', url: server.url, user: 'user', password: 'pass')];
final pair = LiveSyncPair(p.join(local.path, 'live'), 'dav://$id/mirror', method: 'rsync', delete: true, deleteConfirmed: true); final pair = LiveSyncPair(p.join(local.path, 'live'), 'dav://$id/mirror', method: 'rsync', delete: true, deleteConfirmed: true);
c.settings.liveSyncPairs = [pair]; c.settings.liveSyncPairs = [pair];
final live = LiveSync(c); final live = LiveSync(c);
@ -359,4 +361,31 @@ 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)));
test('관리자 3a 판단 (10/20): 원격 (WebDAV) 쌍도 확인한 뒤 옵션이 바뀌어 지울 것이 늘면 다시 묻는다', () async {
lf('rq/a.txt', 'a');
rf('rqd/only.tmp', 'o');
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..webdavServers = [DavServer(id: id, name: '집 NAS', url: server.url, user: 'user', password: 'pass')]
..rsyncOptions = '-av --exclude=*.tmp'
..liveSyncPairs = [
LiveSyncPair(p.join(local.path, 'rq'), 'dav://$id/rqd',
method: 'rsync', delete: true, deleteConfirmed: true, confirmedOptions: '-av --exclude=*.tmp'),
];
final live = LiveSync(c);
addTearDown(live.dispose);
LiveSyncPair pair() => c.settings.liveSyncPairs.single;
final k = LiveSync.keyOf(pair());
await live.syncNow(pair());
expect(live.toDelete[k], isNull);
c.settings.rsyncOptions = '-av --exclude=*.tmp --delete-excluded';
await live.syncNow(pair());
expect(live.toDelete[k], ['only.tmp']);
expect(live.reasked, contains(k));
expect(File(p.join(remote.path, 'rqd', 'only.tmp')).existsSync(), isTrue, reason: '확인 전에는 지우지 않음');
await live.decideDelete(pair(), delete: true);
expect(File(p.join(remote.path, 'rqd', 'only.tmp')).existsSync(), isFalse);
expect(pair().confirmedOptions, '-av --exclude=*.tmp --delete-excluded');
});
} }