diff --git a/integration_test/windows_rsync_narrow_shot_test.dart b/integration_test/windows_rsync_narrow_shot_test.dart new file mode 100644 index 0000000..7496546 --- /dev/null +++ b/integration_test/windows_rsync_narrow_shot_test.dart @@ -0,0 +1,98 @@ +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/l10n/tr.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/explorer_page.dart'; +import 'package:jj_mkvmaker/ui/theme.dart'; +import 'package:path/path.dart' as p; + +import 'support/temp_dir.dart'; + +/// 28 확인 (10/20): 좁은 Rsync 화면의 [오른쪽] 탭을 처음 볼 때 그 창의 지금 폴더가 보이는지 · 확인 창의 체크 제목 +/// "(--remove-source-files)" 가 "(--" 에서 갈리지 않는지를 412 · 960, ko · en · ja 로 찍는다 (시험 폴더 안에서만, [취소]). +/// 실행: flutter test integration_test\windows_rsync_narrow_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_rsync_narrow'); + for (final lang in ['ko', 'en', 'ja']) { + for (final (w, h) in [(412, 900), (960, 600)]) { + testWidgets('좁은 Rsync 화면 $lang ${w}x$h', (t) async { + 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_rsnarrow_shot_'); + // 맨 위에 폴더가 많고 (C: 처럼) 두 창의 지금 폴더는 깊은 곳 + for (var i = 0; i < 60; i++) { + Directory(p.join(tmp.path, 'a${i.toString().padLeft(2, '0')}')).createSync(); + } + final l = p.join(tmp.path, 'zz', 'deep', 'L'), r = p.join(tmp.path, 'zz', 'deep', 'R'); + File(p.join(l, 'sub', 'a.txt')) + ..createSync(recursive: true) + ..writeAsStringSync('a'); + Directory(p.join(r, 'rsub')).createSync(recursive: true); + for (var i = 0; i < 20; i++) { + Directory(p.join(tmp.path, 'zz${i.toString().padLeft(2, '0')}')).createSync(); + } + ExplorerPage.debugVolumes = () => [(tmp.path, 'T:')]; + addTearDown(() => ExplorerPage.debugVolumes = null); + final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + c.settings + ..explorerOrientation = 'side' + ..rsyncPaths = [l, r]; + final shotKey = GlobalKey(); + await t.pumpWidget(MaterialApp( + theme: buildTheme(), + debugShowCheckedModeBanner: false, + builder: (_, child) => RepaintBoundary(key: shotKey, child: child), + home: ExplorerPage(c: c, rsync: true), + )); + Future settle([int n = 10]) async { + for (var i = 0; i < n; i++) { + await t.runAsync(() => Future.delayed(const Duration(milliseconds: 60))); + await t.pump(const Duration(milliseconds: 60)); + } + } + + Future shot(String name) async { + await settle(3); + expect(t.takeException(), isNull, reason: '$name 넘침'); + 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, 'rsync_narrow_${name}_${lang}_$w.png')).writeAsBytesSync(bytes!.buffer.asUint8List()); + } + + await settle(20); + final narrow = find.text('${tr('오른쪽')} · R').evaluate().isNotEmpty; + await t.tap(find.text('sub').hitTestable()); + await settle(); + if (narrow) { + await t.tap(find.text('${tr('오른쪽')} · R')); + await settle(15); + expect(find.text('rsub').hitTestable(), findsOneWidget, reason: '[오른쪽] 탭을 처음 보면 지금 폴더'); + await shot('1_right_tab'); + } + await t.tap(find.text('rsub').hitTestable()); + await settle(); + await t.tap(find.text(tr('좌 → 우')).first); + await settle(25); + await shot('2_confirm'); + await t.tap(find.text(tr('취소')).last); + await settle(); + expect(File(p.join(l, 'sub', 'a.txt')).existsSync(), isTrue, reason: '찍기만 - 실행하지 않음'); + }); + } + } +} diff --git a/lib/ui/copy_sync_settings.dart b/lib/ui/copy_sync_settings.dart index f7342de..bb0b5ef 100644 --- a/lib/ui/copy_sync_settings.dart +++ b/lib/ui/copy_sync_settings.dart @@ -19,7 +19,7 @@ import 'rsync_setup.dart'; import 'schedule_editor.dart'; import 'theme.dart'; import 'setting_tile.dart'; -import 'sync_preview_view.dart' show OptionTextFormatter, optionProblemText; +import 'sync_preview_view.dart' show OptionTextFormatter, keepOptionsTogether, optionProblemText; /// 환경 설정의 복사 · 동기화 부분. /// - 파일 탐색기 ([rsync] false): 파일 / 폴더 복사 방법 (현재 방식 · robocopy), robocopy 옵션, 속도 제한 @@ -183,7 +183,7 @@ class _CopySyncSettingsState extends State { value: c.settings.copySkipNewer, onChanged: (v) => c.updateSettings((x) => x.copySkipNewer = v), title: Text(tr('받는 쪽이 더 새것이면 건너뛰기')), - subtitle: Text(tr('현재 방식 · robocopy · WebDAV 로 복사 · 이동할 때 (rsync 의 -u, robocopy 의 /XO 와 같음). 건너뛴 파일은 이동이라도 원본에 남깁니다')), + subtitle: Text(keepOptionsTogether(tr('현재 방식 · robocopy · WebDAV 로 복사 · 이동할 때 (rsync 의 -u, robocopy 의 /XO 와 같음). 건너뛴 파일은 이동이라도 원본에 남깁니다'))), ); Widget _rsyncPart() { @@ -342,7 +342,7 @@ class _LiveSyncTile extends StatelessWidget { value: delete, onChanged: (v) => set(() => delete = v ?? false), title: Text(tr('원본에 없는 것을 대상에서 지우기')), - subtitle: Text(tr('rsync --delete · robocopy /PURGE. 끄면 대상에 더하기 · 바꾸기만')), + subtitle: Text(keepOptionsTogether(tr('rsync --delete · robocopy /PURGE. 끄면 대상에 더하기 · 바꾸기만'))), ), SettingTile( contentPadding: EdgeInsets.zero, diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index ad4924b..d4d87ad 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -243,6 +243,9 @@ class _Pane extends ChangeNotifier { final back = []; final forward = []; bool traveling = false; + + /// 이 창의 목록이 화면에 붙어 있는지 (새로 붙을 때 지금 폴더를 보이게 - [_ExplorerPageState._revealNewViews]) + bool onScreen = false; String? focused; final expanded = {}; final cache = >{}; @@ -1056,19 +1059,30 @@ class _ExplorerPageState extends State { } /// 그 항목이 목록 위쪽 1/4 쯤에 보이게 스크롤 (깊은 폴더로 가면 위쪽 폴더들의 다른 항목에 밀려 안 보이므로) - void _reveal(_Pane pane, String path) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted || !pane.scroll.hasClients) return; - // 화면의 트리와 같은 줄로 센다 (폴더 + 파일 목록 배치 · Rsync 화면은 폴더만) - final i = _rows(pane, foldersOnly: _split || widget.rsync).indexWhere((r) => r.kind == 0 && samePath(r.entry.path, path)); - if (i < 0) return; - final pos = pane.scroll.position; - // 180: 끝 (maxScrollExtent) 으로 자르지 않는다 - 줄이 막 늘어난 때는 끝이 아직 앞의 것이라 보던 폴더로 못 간다. - // 넘치면 다음 배치 때 실제 끝으로 맞춰진다 - pane.scroll.jumpTo((i * _look.rowHeightOf(context) - pos.viewportDimension / 4).clamp(0.0, double.infinity)); - }); + void _reveal(_Pane pane, String path) => WidgetsBinding.instance.addPostFrameCallback((_) => _revealNow(pane, path)); + + void _revealNow(_Pane pane, String path) { + if (!mounted || !pane.scroll.hasClients) return; + // 화면의 트리와 같은 줄로 센다 (폴더 + 파일 목록 배치 · Rsync 화면은 폴더만) + final i = _rows(pane, foldersOnly: _split || widget.rsync).indexWhere((r) => r.kind == 0 && samePath(r.entry.path, path)); + if (i < 0) return; + final pos = pane.scroll.position; + // 180: 끝 (maxScrollExtent) 으로 자르지 않는다 - 줄이 막 늘어난 때는 끝이 아직 앞의 것이라 보던 폴더로 못 간다. + // 넘치면 다음 배치 때 실제 끝으로 맞춰진다 + pane.scroll.jumpTo((i * _look.rowHeightOf(context) - pos.viewportDimension / 4).clamp(0.0, double.infinity)); } + /// 28 확인 (10/20): 창의 목록이 화면에 새로 붙으면 (좁은 Rsync 화면의 [오른쪽] 탭을 처음 볼 때 · 폭이 바뀌어 두 창이 될 때) + /// 그 창의 지금 폴더를 보이게 한다. 켤 때 그려지지 않은 창은 [_goTo] 의 스크롤을 건너뛰어, 나중에 보면 맨 위 (저장 장치) 에 있었다 + void _revealNewViews() => WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + for (final pane in _panes) { + final on = pane.scroll.hasClients; + if (on && !pane.onScreen && pane.current.isNotEmpty) _revealNow(pane, pane.current); + pane.onScreen = on; + } + }); + /// 75: 창마다 뒤로 · 앞으로 (가 본 폴더로) Future _travel(_Pane pane, {required bool forward}) async { // 앞의 이동이 끝나기 전에 또 불리면 (빨리 두 번 · 같은 누름이 두 번 들어옴) 무시 - 내역에서 두 칸이 빠지지 않게 @@ -3464,7 +3478,7 @@ class _ExplorerPageState extends State { style: const TextStyle(fontSize: 12, color: Colors.orangeAccent)), // 1280×720 등 낮은 화면에서도 보이게: 원본 지우기 체크 · 경고는 비교 목록보다 위에 (178) if (both) ...[ - Text(tr('양쪽을 함께: 받는 쪽이 더 새 파일은 덮어쓰지 않습니다 (-u).'), + Text(keepOptionsTogether(tr('양쪽을 함께: 받는 쪽이 더 새 파일은 덮어쓰지 않습니다 (-u).')), style: const TextStyle(fontSize: 12, color: JjColors.textDim)), if (optionMove) Text(tr('양쪽 (⇄) 에서는 원본 파일을 지우지 않습니다 (옵션의 --remove-source-files 는 쓰지 않음).'), @@ -3479,7 +3493,7 @@ class _ExplorerPageState extends State { preview = null; previewError = null; }), - title: Text(tr('원본 파일 지우기 (--remove-source-files)')), + title: Text(keepOptionsTogether(tr('원본 파일 지우기 (--remove-source-files)'))), subtitle: Text(tr('대상으로 옮긴 파일을 원본에서 지웁니다 (이동)')), ), if (removeSource) ...[ @@ -4410,6 +4424,8 @@ class _ExplorerPageState extends State { final dual = _dual; final split = _split; return LayoutBuilder(builder: (context, box) { + // 폭이 바뀌면 (좁은 한 창 ↔ 두 창) 이 builder 만 다시 불린다 - 그때도 새로 붙은 창을 보이게 + if (_ready) _revealNewViews(); final side = !(dual || split) || switch (s.explorerOrientation) { 'side' => true, diff --git a/lib/ui/monitor_page.dart b/lib/ui/monitor_page.dart index f48901f..f264efb 100644 --- a/lib/ui/monitor_page.dart +++ b/lib/ui/monitor_page.dart @@ -21,7 +21,7 @@ import 'path_label.dart'; import 'rsync_setup.dart'; import 'schedule_editor.dart'; import '../core/sync_preview.dart' show optionsRemoveSource; -import 'sync_preview_view.dart' show FilterLimitNote, OptionProblemNote, OptionTextFormatter, confirmDeletingRun, effectiveRunText, unbreakableOption; +import 'sync_preview_view.dart' show FilterLimitNote, OptionProblemNote, OptionTextFormatter, confirmDeletingRun, effectiveRunText, keepOptionsTogether, unbreakableOption; import 'theme.dart'; /// 모니터링 (파일 탐색기 > 모니터링): 복사 · rsync 와 lsync (실시간 동기화) 를 따로 보여 준다. @@ -135,9 +135,9 @@ Future _liveDeleteDialog(BuildContext context, LiveSync live, LiveSyncPai 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]), + Text(keepOptionsTogether(trf('거른 것 {0}개도 지워집니다 (--delete-excluded)', [filtered.length])), style: const TextStyle(fontWeight: FontWeight.w600, color: JjColors.danger)), - Text(tr('rsync 옵션에서 뺀 것 (--exclude 등) 이지만 --delete-excluded 가 있어 대상에서 지웁니다. 지키려면 옵션에서 --delete-excluded 를 빼세요.'), + Text(keepOptionsTogether(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)), diff --git a/lib/ui/sync_preview_view.dart b/lib/ui/sync_preview_view.dart index b622115..e6508a8 100644 --- a/lib/ui/sync_preview_view.dart +++ b/lib/ui/sync_preview_view.dart @@ -126,7 +126,7 @@ class _SyncPreviewViewState extends State { IconButton(tooltip: tr('다시 비교'), icon: const Icon(Icons.refresh, size: 18), onPressed: _run), ]), if (dels > 0) - Text(trf('받는 쪽에서 {0}개가 지워집니다 (--delete, 되돌릴 수 없음)', [dels]), + Text(keepOptionsTogether(trf('받는 쪽에서 {0}개가 지워집니다 (--delete, 되돌릴 수 없음)', [dels])), style: const TextStyle(fontSize: 12, color: Colors.redAccent, fontWeight: FontWeight.w600)), // 15 검토 F4: 받는 쪽에만 있는 받다 만 파일 (*.jjpart 등) 도 지워진다는 것을 따로 if (partials > 0) @@ -316,6 +316,12 @@ Future confirmDeletingRun(BuildContext context, CopyTask t, {bool includeM /// 빈칸은 줄바꿈 없는 빈칸, - · / 뒤에는 줄바꿈을 막는 글자 (U+2060, 보이지 않음) String unbreakableOption(String s) => s.replaceAll(' ', '\u00A0').replaceAllMapped(RegExp(r'[-/]'), (m) => '${m[0]}\u2060'); +/// 28 확인 (10/20): 글 안의 옵션 이름만 묶는다 - 괄호째 ("(--remove-source-files)" · "(--delete, 되돌릴 수 없음)") 와 따로 쓴 +/// "--delete-excluded" · "-u" · "/PURGE". 예전에는 en 412 에서 "(--" 에서 갈렸다. 나머지 글은 그대로 줄바꿈 +String keepOptionsTogether(String s) => s + .replaceAllMapped(RegExp(r'\([-/][^()]*\)'), (m) => unbreakableOption(m[0]!)) + .replaceAllMapped(RegExp(r'(? unbreakableOption(m[0]!)); + /// 옵션 입력 칸: 붙여 넣거나 친 글의 U+00A0 · U+2060 등을 고쳐 받는다 ([cleanOptionText], 관리자 지시 10/20). /// 화면의 묶은 옵션 글 ([unbreakableOption]) 이나 다른 곳의 글을 붙여 넣어도 rsync 가 다르게 읽지 않게 class OptionTextFormatter extends TextInputFormatter { diff --git a/test/dismiss_test.dart b/test/dismiss_test.dart index e5400e3..4fbe65d 100644 --- a/test/dismiss_test.dart +++ b/test/dismiss_test.dart @@ -365,7 +365,7 @@ void main() { expect(find.text('rsync'), findsWidgets); // 222: 원본이 대상 안 - 복사면 "복사됩니다 · 원본 폴더 그대로", 이동이면 "옮겨집니다 · 원본 파일은 지워짐" expect(find.textContaining('바로 아래에 복사됩니다'), findsOneWidget); - await tester.tap(find.text('원본 파일 지우기 (--remove-source-files)')); + await tester.tap(find.text(keepOptionsTogether('원본 파일 지우기 (--remove-source-files)'))); await tester.pump(); expect(find.textContaining('바로 아래로 옮겨집니다 (원본 파일은 지워지고'), findsOneWidget); expect(find.textContaining('바로 아래에 복사됩니다'), findsNothing); diff --git a/test/explorer_parallel_test.dart b/test/explorer_parallel_test.dart index e978605..e8082f4 100644 --- a/test/explorer_parallel_test.dart +++ b/test/explorer_parallel_test.dart @@ -94,6 +94,47 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('28 확인 (10/20): 좁은 Rsync 화면의 [오른쪽] 탭 · 넓게 바꾼 두 창도 그 창의 지금 폴더가 보인다 (저장 장치 맨 위에 머물지 않음)', (tester) async { + final tmp = Directory.systemTemp.createTempSync('jj_rsync_reveal_'); + addTearDown(() => tmp.deleteSync(recursive: true)); + // 맨 위에 폴더가 많고 (C: 처럼) 두 창의 지금 폴더는 깊은 곳 - 펼쳐도 스크롤하지 않으면 화면 밖 + for (var i = 0; i < 80; i++) { + Directory(p.join(tmp.path, 'a${i.toString().padLeft(2, '0')}')).createSync(); + } + final l = p.join(tmp.path, 'zz', 'deep', 'L'), r = p.join(tmp.path, 'zz', 'deep', 'R'); + Directory(p.join(l, 'sub')).createSync(recursive: true); + Directory(p.join(r, 'rsub')).createSync(recursive: true); + ExplorerPage.debugVolumes = () => [(tmp.path, 'T:')]; + addTearDown(() => ExplorerPage.debugVolumes = null); + final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + c.settings + ..explorerOrientation = 'side' + ..rsyncPaths = [l, r]; + tester.view.physicalSize = const Size(412, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c, rsync: true)))); + await settle(tester, () => find.text('sub').hitTestable().evaluate().isNotEmpty); + expect(find.text('sub').hitTestable(), findsOneWidget, reason: '왼쪽은 처음부터 지금 폴더'); + // [오른쪽] 탭을 처음 보면 그 창의 지금 폴더 (R) 로 - 예전에는 맨 위 (a00 …) 에서 시작 + await tester.tap(find.text('오른쪽 · R')); + await settle(tester, () => find.text('rsub').hitTestable().evaluate().isNotEmpty, rounds: 40); + expect(find.text('rsub').hitTestable(), findsOneWidget); + // 넓게 바꾸면 두 창 - 둘 다 지금 폴더가 보임 + tester.view.physicalSize = const Size(1280, 800); + await settle(tester, () => find.text('sub').hitTestable().evaluate().isNotEmpty && find.text('rsub').hitTestable().evaluate().isNotEmpty, + rounds: 40); + expect(find.text('sub').hitTestable(), findsOneWidget); + expect(find.text('rsub').hitTestable(), findsOneWidget); + // 다시 좁게 → [왼쪽] 탭 (지금 보이는 창) 도 지금 폴더 + tester.view.physicalSize = const Size(412, 900); + await settle(tester, () => find.text('왼쪽 · L').evaluate().isNotEmpty); + await tester.tap(find.text('왼쪽 · L')); + await settle(tester, () => find.text('sub').hitTestable().evaluate().isNotEmpty, rounds: 40); + expect(find.text('sub').hitTestable(), findsOneWidget); + expect(tester.takeException(), isNull); + }); + testWidgets('208: 폰 가로 (낮은 화면) Rsync 화면에서도 방향 실행 버튼 · 모니터링은 늘 보인다 ([더 보기] 로 가는 것은 뒤쪽)', (tester) async { final tmp = Directory.systemTemp.createTempSync('jj_rsync_low_'); addTearDown(() => tmp.deleteSync(recursive: true)); diff --git a/test/explorer_test.dart b/test/explorer_test.dart index 505f963..c1ad763 100644 --- a/test/explorer_test.dart +++ b/test/explorer_test.dart @@ -27,6 +27,7 @@ import 'package:jj_mkvmaker/ui/path_label.dart'; import 'package:path/path.dart' as p; import 'support/fake_recycle_bin.dart'; import 'package:jj_mkvmaker/platform/windows/recycle_bin.dart' show recycleBinForTest; +import 'package:jj_mkvmaker/ui/sync_preview_view.dart' show keepOptionsTogether; /// 다른 앱으로 열기를 기록만 하는 셸 (실제로 프로그램을 띄우지 않게) class _Shell extends NoopShell { @@ -1164,7 +1165,7 @@ void main() { await tester.tap(find.text('취소')); await calm(tester); expect(c.settings.copyTasks, isEmpty); - expect(find.text('원본 파일 지우기 (--remove-source-files)'), findsNothing); // 양쪽에는 없음 + expect(find.text(keepOptionsTogether('원본 파일 지우기 (--remove-source-files)')), findsNothing); // 양쪽에는 없음 // → 는 한 방향 · -u 없이 · 원본 파일 지우기를 고를 수 있다 (고르면 원본 폴더 남김 / 지움) await act(tester, () => tester.tap(find.text('좌 → 우'))); await settle(tester, () => find.textContaining('비교하는 중').evaluate().isEmpty, rounds: 400); @@ -1173,7 +1174,7 @@ void main() { // 옵션 낱말 -u 만 (폴더 이름 vscode-stable-user-x64 같은 것은 아님 - 시험 임시 폴더 위가 %TEMP% 일 때) expect(find.textContaining(RegExp(r'(^|\s)-u(\s|$)')), findsNothing); expect(find.text('빈 폴더 지움 · 원본 폴더는 남김'), findsNothing); - await tester.tap(find.text('원본 파일 지우기 (--remove-source-files)')); + await tester.tap(find.text(keepOptionsTogether('원본 파일 지우기 (--remove-source-files)'))); await calm(tester); expect(find.textContaining('-avPog --remove-source-files'), findsOneWidget); expect(find.text('빈 폴더 지움 · 원본 폴더는 남김'), findsOneWidget); diff --git a/test/live_sync_scene_test.dart b/test/live_sync_scene_test.dart index 6834bd8..182d04b 100644 --- a/test/live_sync_scene_test.dart +++ b/test/live_sync_scene_test.dart @@ -16,6 +16,7 @@ import 'package:jj_mkvmaker/ui/monitor_page.dart'; import 'package:path/path.dart' as p; import 'support/rsync_scenes.dart'; +import 'package:jj_mkvmaker/ui/sync_preview_view.dart' show keepOptionsTogether; /// 관리자 지시 (10/20): 실시간 동기화 (로컬 rsync 쌍) 를 28 의 장면 · 옵션으로 - 결과 트리 = 실제 rsync, /// 지울지 묻는 목록 = rsync 가 지우는 것, 맞춘 뒤 "맞출 것" 이 남지 않음 (거른 것을 기다리는 것으로 세지 않음) @@ -106,9 +107,9 @@ void main() { await tester.pump(); await tester.pump(const Duration(milliseconds: 300)); expect(find.text('대상에서 3개를 지울까요?'), findsOneWidget); - expect(find.text('거른 것 2개도 지워집니다 (--delete-excluded)'), findsOneWidget); + expect(find.text(keepOptionsTogether('거른 것 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}; + final y = {for (final n in ['only.txt', '거른 것 2개도 지워집니다 (--delete-excluded)', 'skip.tmp', 'cache']) n: tester.getTopLeft(find.text(keepOptionsTogether(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); diff --git a/test/rsync_safety_test.dart b/test/rsync_safety_test.dart index aae413d..12536eb 100644 --- a/test/rsync_safety_test.dart +++ b/test/rsync_safety_test.dart @@ -13,6 +13,7 @@ import 'package:path/path.dart' as p; import 'explorer_test.dart' show act, settle; import 'support/dav_server.dart'; +import 'package:jj_mkvmaker/ui/sync_preview_view.dart' show keepOptionsTogether; /// 95 · 96 · 97 · 100: Rsync 화면의 지우기 - 확인 창 · 미리 보기 · 실행이 같고, ⇄ 는 차례로 (← 는 지우지 않음) void main() { @@ -78,7 +79,7 @@ void main() { expect(find.textContaining('-avPog -u --delete'), findsNothing); expect(find.textContaining('rsync -avPog -u'), findsWidgets); expect(runButton(tester).onPressed, isNotNull); - expect(find.text('받는 쪽에서 1개가 지워집니다 (--delete, 되돌릴 수 없음)'), findsOneWidget); + expect(find.text(keepOptionsTogether('받는 쪽에서 1개가 지워집니다 (--delete, 되돌릴 수 없음)')), findsOneWidget); await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '실행'))); await settle(tester, () => find.byType(LinearProgressIndicator).evaluate().isEmpty && find.textContaining('rsync 끝').evaluate().isNotEmpty, rounds: 400); @@ -101,12 +102,12 @@ void main() { expect(find.textContaining('rsync -avPog --delete'), findsNothing); expect(find.textContaining('− b.txt'), findsNothing); // 원본 파일 지우기 (이동 작업) → 그 작업의 옵션 (--delete) 으로 다시 보이고 다시 비교한다 - await tester.tap(find.text('원본 파일 지우기 (--remove-source-files)')); + await tester.tap(find.text(keepOptionsTogether('원본 파일 지우기 (--remove-source-files)'))); await tester.pump(); expect(find.textContaining('rsync -avPog --delete --remove-source-files'), findsOneWidget); await waitCompare(tester); expect(find.textContaining('− b.txt'), findsOneWidget); - expect(find.text('받는 쪽에서 1개가 지워집니다 (--delete, 되돌릴 수 없음)'), findsOneWidget); + expect(find.text(keepOptionsTogether('받는 쪽에서 1개가 지워집니다 (--delete, 되돌릴 수 없음)')), findsOneWidget); expect(find.widgetWithText(FilledButton, '이동'), findsOneWidget); await tester.tap(find.text('취소')); await tester.pumpAndSettle(); @@ -118,7 +119,7 @@ void main() { await open(tester); await act(tester, () => tester.tap(find.text('좌 → 우'))); await waitCompare(tester); - expect(find.text('받는 쪽에서 1개가 지워집니다 (--delete, 되돌릴 수 없음)'), findsOneWidget); // --del 도 지우기로 알아본다 + expect(find.text(keepOptionsTogether('받는 쪽에서 1개가 지워집니다 (--delete, 되돌릴 수 없음)')), findsOneWidget); // --del 도 지우기로 알아본다 await tester.tap(find.byTooltip('다시 비교')); await tester.pump(); expect(runButton(tester).onPressed, isNull); diff --git a/test/sync_preview_view_test.dart b/test/sync_preview_view_test.dart index 9eeeb41..799c3a8 100644 --- a/test/sync_preview_view_test.dart +++ b/test/sync_preview_view_test.dart @@ -50,4 +50,16 @@ void main() { expect(find.textContaining('(더 새 파일을 덮어씀)'), findsOneWidget); expect(find.textContaining('(받는 쪽이 더 새것 - 건너뜀)'), findsOneWidget); }); + + test('28 확인 (10/20): keepOptionsTogether - 글 안의 옵션 덩어리만 묶고 (괄호째 · 따로 쓴 것) 나머지는 그대로', () { + const wj = '\u2060', nb = '\u00A0'; + expect(keepOptionsTogether('원본 파일 지우기 (--remove-source-files)'), '원본 파일 지우기 (-$wj-${wj}remove-${wj}source-${wj}files)'); + expect(keepOptionsTogether('Delete source files (--remove-source-files)'), 'Delete source files (-$wj-${wj}remove-${wj}source-${wj}files)', + reason: '앞 글의 빈칸은 그대로 (거기서는 줄을 바꿀 수 있음)'); + expect(keepOptionsTogether('지워집니다 (--delete, 되돌릴 수 없음)'), '지워집니다 (-$wj-${wj}delete,$nb되돌릴$nb수$nb없음)'); + expect(keepOptionsTogether('rsync --delete · robocopy /PURGE. 끄면'), 'rsync -$wj-${wj}delete · robocopy /${wj}PURGE. 끄면'); + expect(keepOptionsTogether('(rsync 의 -u, robocopy 의 /XO 와 같음)'), '(rsync 의 -${wj}u, robocopy 의 /${wj}XO 와 같음)'); + // 낱말 가운데의 - · / 는 그대로 (real-time · a/b) + expect(keepOptionsTogether('real-time 동기화 · a/b · WebDAV'), 'real-time 동기화 · a/b · WebDAV'); + }); } diff --git a/test/sync_problem_test.dart b/test/sync_problem_test.dart index ff90cc4..70ef346 100644 --- a/test/sync_problem_test.dart +++ b/test/sync_problem_test.dart @@ -15,6 +15,7 @@ import 'package:jj_mkvmaker/ui/monitor_page.dart'; import 'package:path/path.dart' as p; import 'explorer_test.dart' show act, calm, settle; +import 'package:jj_mkvmaker/ui/sync_preview_view.dart' show keepOptionsTogether; /// 68 · 70 · 71 void main() { @@ -204,7 +205,7 @@ void main() { expect(tester.widget(find.widgetWithText(FilledButton, '실행')).onPressed, isNull); await settle(tester, () => find.textContaining('비교하는 중').evaluate().isEmpty, rounds: 400); // 끝나면: 지울 목록 (빨강) · [실행 (1개 지움)] 켜짐 - expect(find.text('받는 쪽에서 1개가 지워집니다 (--delete, 되돌릴 수 없음)'), findsOneWidget); + expect(find.text(keepOptionsTogether('받는 쪽에서 1개가 지워집니다 (--delete, 되돌릴 수 없음)')), findsOneWidget); expect(find.textContaining('− only_r.txt'), findsOneWidget); expect(tester.widget(find.widgetWithText(FilledButton, '실행')).onPressed, isNotNull); await tester.tap(find.text('취소'));