next10: hwp_pdf (ced0ae9) + rsync_narrow (dfed3fa: 412 Rsync 화면 오른쪽 창 · 옵션 이름 줄바꿈) - 시험판 986

합침 (d3): copy_sync_settings 의 import 한 줄 (optionProblemText · keepOptionsTogether 둘 다).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
next10 v2026.10.18_986
RyuJongJin 2026-10-20 17:54:41 +09:00
commit 56c632d372
12 changed files with 207 additions and 30 deletions

View File

@ -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<void> settle([int n = 10]) async {
for (var i = 0; i < n; i++) {
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 60)));
await t.pump(const Duration(milliseconds: 60));
}
}
Future<void> 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: '찍기만 - 실행하지 않음');
});
}
}
}

View File

@ -19,7 +19,7 @@ import 'rsync_setup.dart';
import 'schedule_editor.dart'; import 'schedule_editor.dart';
import 'theme.dart'; import 'theme.dart';
import 'setting_tile.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 옵션, 속도 제한 /// - 파일 탐색기 ([rsync] false): 파일 / 폴더 복사 방법 (현재 방식 · robocopy), robocopy 옵션, 속도 제한
@ -183,7 +183,7 @@ class _CopySyncSettingsState extends State<CopySyncSettings> {
value: c.settings.copySkipNewer, value: c.settings.copySkipNewer,
onChanged: (v) => c.updateSettings((x) => x.copySkipNewer = v), onChanged: (v) => c.updateSettings((x) => x.copySkipNewer = v),
title: Text(tr('받는 쪽이 더 새것이면 건너뛰기')), title: Text(tr('받는 쪽이 더 새것이면 건너뛰기')),
subtitle: Text(tr('현재 방식 · robocopy · WebDAV 로 복사 · 이동할 때 (rsync 의 -u, robocopy 의 /XO 와 같음). 건너뛴 파일은 이동이라도 원본에 남깁니다')), subtitle: Text(keepOptionsTogether(tr('현재 방식 · robocopy · WebDAV 로 복사 · 이동할 때 (rsync 의 -u, robocopy 의 /XO 와 같음). 건너뛴 파일은 이동이라도 원본에 남깁니다'))),
); );
Widget _rsyncPart() { Widget _rsyncPart() {
@ -342,7 +342,7 @@ class _LiveSyncTile extends StatelessWidget {
value: delete, value: delete,
onChanged: (v) => set(() => delete = v ?? false), onChanged: (v) => set(() => delete = v ?? false),
title: Text(tr('원본에 없는 것을 대상에서 지우기')), title: Text(tr('원본에 없는 것을 대상에서 지우기')),
subtitle: Text(tr('rsync --delete · robocopy /PURGE. 끄면 대상에 더하기 · 바꾸기만')), subtitle: Text(keepOptionsTogether(tr('rsync --delete · robocopy /PURGE. 끄면 대상에 더하기 · 바꾸기만'))),
), ),
SettingTile( SettingTile(
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,

View File

@ -245,6 +245,9 @@ class _Pane extends ChangeNotifier {
final back = <String>[]; final back = <String>[];
final forward = <String>[]; final forward = <String>[];
bool traveling = false; bool traveling = false;
/// 이 창의 목록이 화면에 붙어 있는지 (새로 붙을 때 지금 폴더를 보이게 - [_ExplorerPageState._revealNewViews])
bool onScreen = false;
String? focused; String? focused;
final expanded = <String>{}; final expanded = <String>{};
final cache = <String, List<FileEntry>>{}; final cache = <String, List<FileEntry>>{};
@ -1058,8 +1061,9 @@ class _ExplorerPageState extends State<ExplorerPage> {
} }
/// 그 항목이 목록 위쪽 1/4 쯤에 보이게 스크롤 (깊은 폴더로 가면 위쪽 폴더들의 다른 항목에 밀려 안 보이므로) /// 그 항목이 목록 위쪽 1/4 쯤에 보이게 스크롤 (깊은 폴더로 가면 위쪽 폴더들의 다른 항목에 밀려 안 보이므로)
void _reveal(_Pane pane, String path) { void _reveal(_Pane pane, String path) => WidgetsBinding.instance.addPostFrameCallback((_) => _revealNow(pane, path));
WidgetsBinding.instance.addPostFrameCallback((_) {
void _revealNow(_Pane pane, String path) {
if (!mounted || !pane.scroll.hasClients) return; if (!mounted || !pane.scroll.hasClients) return;
// 화면의 트리와 같은 줄로 센다 (폴더 + 파일 목록 배치 · Rsync 화면은 폴더만) // 화면의 트리와 같은 줄로 센다 (폴더 + 파일 목록 배치 · Rsync 화면은 폴더만)
final i = _rows(pane, foldersOnly: _split || widget.rsync).indexWhere((r) => r.kind == 0 && samePath(r.entry.path, path)); final i = _rows(pane, foldersOnly: _split || widget.rsync).indexWhere((r) => r.kind == 0 && samePath(r.entry.path, path));
@ -1068,9 +1072,19 @@ class _ExplorerPageState extends State<ExplorerPage> {
// 180: 끝 (maxScrollExtent) 으로 자르지 않는다 - 줄이 막 늘어난 때는 끝이 아직 앞의 것이라 보던 폴더로 못 간다. // 180: 끝 (maxScrollExtent) 으로 자르지 않는다 - 줄이 막 늘어난 때는 끝이 아직 앞의 것이라 보던 폴더로 못 간다.
// 넘치면 다음 배치 때 실제 끝으로 맞춰진다 // 넘치면 다음 배치 때 실제 끝으로 맞춰진다
pane.scroll.jumpTo((i * _look.rowHeightOf(context) - pos.viewportDimension / 4).clamp(0.0, double.infinity)); 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: 창마다 뒤로 · 앞으로 (가 본 폴더로) /// 75: 창마다 뒤로 · 앞으로 (가 본 폴더로)
Future<void> _travel(_Pane pane, {required bool forward}) async { Future<void> _travel(_Pane pane, {required bool forward}) async {
// 앞의 이동이 끝나기 전에 또 불리면 (빨리 두 번 · 같은 누름이 두 번 들어옴) 무시 - 내역에서 두 칸이 빠지지 않게 // 앞의 이동이 끝나기 전에 또 불리면 (빨리 두 번 · 같은 누름이 두 번 들어옴) 무시 - 내역에서 두 칸이 빠지지 않게
@ -3507,7 +3521,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
style: const TextStyle(fontSize: 12, color: Colors.orangeAccent)), style: const TextStyle(fontSize: 12, color: Colors.orangeAccent)),
// 1280×720 등 낮은 화면에서도 보이게: 원본 지우기 체크 · 경고는 비교 목록보다 위에 (178) // 1280×720 등 낮은 화면에서도 보이게: 원본 지우기 체크 · 경고는 비교 목록보다 위에 (178)
if (both) ...[ if (both) ...[
Text(tr('양쪽을 함께: 받는 쪽이 더 새 파일은 덮어쓰지 않습니다 (-u).'), Text(keepOptionsTogether(tr('양쪽을 함께: 받는 쪽이 더 새 파일은 덮어쓰지 않습니다 (-u).')),
style: const TextStyle(fontSize: 12, color: JjColors.textDim)), style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
if (optionMove) if (optionMove)
Text(tr('양쪽 (⇄) 에서는 원본 파일을 지우지 않습니다 (옵션의 --remove-source-files 는 쓰지 않음).'), Text(tr('양쪽 (⇄) 에서는 원본 파일을 지우지 않습니다 (옵션의 --remove-source-files 는 쓰지 않음).'),
@ -3522,7 +3536,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
preview = null; preview = null;
previewError = null; previewError = null;
}), }),
title: Text(tr('원본 파일 지우기 (--remove-source-files)')), title: Text(keepOptionsTogether(tr('원본 파일 지우기 (--remove-source-files)'))),
subtitle: Text(tr('대상으로 옮긴 파일을 원본에서 지웁니다 (이동)')), subtitle: Text(tr('대상으로 옮긴 파일을 원본에서 지웁니다 (이동)')),
), ),
if (removeSource) ...[ if (removeSource) ...[
@ -4453,6 +4467,8 @@ class _ExplorerPageState extends State<ExplorerPage> {
final dual = _dual; final dual = _dual;
final split = _split; final split = _split;
return LayoutBuilder(builder: (context, box) { return LayoutBuilder(builder: (context, box) {
// 폭이 바뀌면 (좁은 한 창 ↔ 두 창) 이 builder 만 다시 불린다 - 그때도 새로 붙은 창을 보이게
if (_ready) _revealNewViews();
final side = !(dual || split) || final side = !(dual || split) ||
switch (s.explorerOrientation) { switch (s.explorerOrientation) {
'side' => true, 'side' => true,

View File

@ -21,7 +21,7 @@ import 'path_label.dart';
import 'rsync_setup.dart'; import 'rsync_setup.dart';
import 'schedule_editor.dart'; import 'schedule_editor.dart';
import '../core/sync_preview.dart' show optionsRemoveSource; 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'; import 'theme.dart';
/// 모니터링 (파일 탐색기 > 모니터링): 복사 · rsync 와 lsync (실시간 동기화) 를 따로 보여 준다. /// 모니터링 (파일 탐색기 > 모니터링): 복사 · rsync 와 lsync (실시간 동기화) 를 따로 보여 준다.
@ -135,9 +135,9 @@ Future<bool?> _liveDeleteDialog(BuildContext context, LiveSync live, LiveSyncPai
if (plain.length > 500) Text(trf('… 외 {0}개', [plain.length - 500])), if (plain.length > 500) Text(trf('… 외 {0}개', [plain.length - 500])),
if (filtered.isNotEmpty) ...[ if (filtered.isNotEmpty) ...[
if (plain.isNotEmpty) const SizedBox(height: 10), 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)), 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)), style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
const SizedBox(height: 4), const SizedBox(height: 4),
for (final n in filtered.take(500)) Text(n, style: const TextStyle(fontSize: 12, fontFamily: 'Consolas', color: JjColors.danger)), for (final n in filtered.take(500)) Text(n, style: const TextStyle(fontSize: 12, fontFamily: 'Consolas', color: JjColors.danger)),

View File

@ -126,7 +126,7 @@ class _SyncPreviewViewState extends State<SyncPreviewView> {
IconButton(tooltip: tr('다시 비교'), icon: const Icon(Icons.refresh, size: 18), onPressed: _run), IconButton(tooltip: tr('다시 비교'), icon: const Icon(Icons.refresh, size: 18), onPressed: _run),
]), ]),
if (dels > 0) 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)), style: const TextStyle(fontSize: 12, color: Colors.redAccent, fontWeight: FontWeight.w600)),
// 15 검토 F4: 받는 쪽에만 있는 받다 만 파일 (*.jjpart 등) 도 지워진다는 것을 따로 // 15 검토 F4: 받는 쪽에만 있는 받다 만 파일 (*.jjpart 등) 도 지워진다는 것을 따로
if (partials > 0) if (partials > 0)
@ -316,6 +316,12 @@ Future<bool> confirmDeletingRun(BuildContext context, CopyTask t, {bool includeM
/// 빈칸은 줄바꿈 없는 빈칸, - · / 뒤에는 줄바꿈을 막는 글자 (U+2060, 보이지 않음) /// 빈칸은 줄바꿈 없는 빈칸, - · / 뒤에는 줄바꿈을 막는 글자 (U+2060, 보이지 않음)
String unbreakableOption(String s) => s.replaceAll(' ', '\u00A0').replaceAllMapped(RegExp(r'[-/]'), (m) => '${m[0]}\u2060'); 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'(?<![\w\-\u2060/])(?:--?[A-Za-z][\w-]*(?:=\S*)?|/[A-Z]{2,}\b)'), (m) => unbreakableOption(m[0]!));
/// 옵션 입력 칸: 붙여 넣거나 친 글의 U+00A0 · U+2060 등을 고쳐 받는다 ([cleanOptionText], 관리자 지시 10/20). /// 옵션 입력 칸: 붙여 넣거나 친 글의 U+00A0 · U+2060 등을 고쳐 받는다 ([cleanOptionText], 관리자 지시 10/20).
/// 화면의 묶은 옵션 글 ([unbreakableOption]) 이나 다른 곳의 글을 붙여 넣어도 rsync 가 다르게 읽지 않게 /// 화면의 묶은 옵션 글 ([unbreakableOption]) 이나 다른 곳의 글을 붙여 넣어도 rsync 가 다르게 읽지 않게
class OptionTextFormatter extends TextInputFormatter { class OptionTextFormatter extends TextInputFormatter {

View File

@ -365,7 +365,7 @@ void main() {
expect(find.text('rsync'), findsWidgets); expect(find.text('rsync'), findsWidgets);
// 222: 원본이 대상 안 - 복사면 "복사됩니다 · 원본 폴더 그대로", 이동이면 "옮겨집니다 · 원본 파일은 지워짐" // 222: 원본이 대상 안 - 복사면 "복사됩니다 · 원본 폴더 그대로", 이동이면 "옮겨집니다 · 원본 파일은 지워짐"
expect(find.textContaining('바로 아래에 복사됩니다'), findsOneWidget); expect(find.textContaining('바로 아래에 복사됩니다'), findsOneWidget);
await tester.tap(find.text('원본 파일 지우기 (--remove-source-files)')); await tester.tap(find.text(keepOptionsTogether('원본 파일 지우기 (--remove-source-files)')));
await tester.pump(); await tester.pump();
expect(find.textContaining('바로 아래로 옮겨집니다 (원본 파일은 지워지고'), findsOneWidget); expect(find.textContaining('바로 아래로 옮겨집니다 (원본 파일은 지워지고'), findsOneWidget);
expect(find.textContaining('바로 아래에 복사됩니다'), findsNothing); expect(find.textContaining('바로 아래에 복사됩니다'), findsNothing);

View File

@ -94,6 +94,47 @@ void main() {
expect(tester.takeException(), isNull); 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 { testWidgets('208: 폰 가로 (낮은 화면) Rsync 화면에서도 방향 실행 버튼 · 모니터링은 늘 보인다 ([더 보기] 로 가는 것은 뒤쪽)', (tester) async {
final tmp = Directory.systemTemp.createTempSync('jj_rsync_low_'); final tmp = Directory.systemTemp.createTempSync('jj_rsync_low_');
addTearDown(() => tmp.deleteSync(recursive: true)); addTearDown(() => tmp.deleteSync(recursive: true));

View File

@ -27,6 +27,7 @@ import 'package:jj_mkvmaker/ui/path_label.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'support/fake_recycle_bin.dart'; import 'support/fake_recycle_bin.dart';
import 'package:jj_mkvmaker/platform/windows/recycle_bin.dart' show recycleBinForTest; 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 { class _Shell extends NoopShell {
@ -1164,7 +1165,7 @@ void main() {
await tester.tap(find.text('취소')); await tester.tap(find.text('취소'));
await calm(tester); await calm(tester);
expect(c.settings.copyTasks, isEmpty); expect(c.settings.copyTasks, isEmpty);
expect(find.text('원본 파일 지우기 (--remove-source-files)'), findsNothing); // 양쪽에는 없음 expect(find.text(keepOptionsTogether('원본 파일 지우기 (--remove-source-files)')), findsNothing); // 양쪽에는 없음
// → 는 한 방향 · -u 없이 · 원본 파일 지우기를 고를 수 있다 (고르면 원본 폴더 남김 / 지움) // → 는 한 방향 · -u 없이 · 원본 파일 지우기를 고를 수 있다 (고르면 원본 폴더 남김 / 지움)
await act(tester, () => tester.tap(find.text('좌 → 우'))); await act(tester, () => tester.tap(find.text('좌 → 우')));
await settle(tester, () => find.textContaining('비교하는 중').evaluate().isEmpty, rounds: 400); await settle(tester, () => find.textContaining('비교하는 중').evaluate().isEmpty, rounds: 400);
@ -1173,7 +1174,7 @@ void main() {
// 옵션 낱말 -u 만 (폴더 이름 vscode-stable-user-x64 같은 것은 아님 - 시험 임시 폴더 위가 %TEMP% 일 때) // 옵션 낱말 -u 만 (폴더 이름 vscode-stable-user-x64 같은 것은 아님 - 시험 임시 폴더 위가 %TEMP% 일 때)
expect(find.textContaining(RegExp(r'(^|\s)-u(\s|$)')), findsNothing); expect(find.textContaining(RegExp(r'(^|\s)-u(\s|$)')), findsNothing);
expect(find.text('빈 폴더 지움 · 원본 폴더는 남김'), 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); await calm(tester);
expect(find.textContaining('-avPog --remove-source-files'), findsOneWidget); expect(find.textContaining('-avPog --remove-source-files'), findsOneWidget);
expect(find.text('빈 폴더 지움 · 원본 폴더는 남김'), findsOneWidget); expect(find.text('빈 폴더 지움 · 원본 폴더는 남김'), findsOneWidget);

View File

@ -16,6 +16,7 @@ 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';
import 'package:jj_mkvmaker/ui/sync_preview_view.dart' show keepOptionsTogether;
/// 관리자 지시 (10/20): 실시간 동기화 (로컬 rsync 쌍) 를 28 의 장면 · 옵션으로 - 결과 트리 = 실제 rsync, /// 관리자 지시 (10/20): 실시간 동기화 (로컬 rsync 쌍) 를 28 의 장면 · 옵션으로 - 결과 트리 = 실제 rsync,
/// 지울지 묻는 목록 = rsync 가 지우는 것, 맞춘 뒤 "맞출 것" 이 남지 않음 (거른 것을 기다리는 것으로 세지 않음) /// 지울지 묻는 목록 = rsync 가 지우는 것, 맞춘 뒤 "맞출 것" 이 남지 않음 (거른 것을 기다리는 것으로 세지 않음)
@ -106,9 +107,9 @@ void main() {
await tester.pump(); await tester.pump();
await tester.pump(const Duration(milliseconds: 300)); await tester.pump(const Duration(milliseconds: 300));
expect(find.text('대상에서 3개를 지울까요?'), findsOneWidget); 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['only.txt']! < y['거른 것 2개도 지워집니다 (--delete-excluded)']!, isTrue);
expect(y['거른 것 2개도 지워집니다 (--delete-excluded)']! < y['skip.tmp']!, isTrue); expect(y['거른 것 2개도 지워집니다 (--delete-excluded)']! < y['skip.tmp']!, isTrue);
expect(y['거른 것 2개도 지워집니다 (--delete-excluded)']! < y['cache']!, isTrue); expect(y['거른 것 2개도 지워집니다 (--delete-excluded)']! < y['cache']!, isTrue);

View File

@ -13,6 +13,7 @@ import 'package:path/path.dart' as p;
import 'explorer_test.dart' show act, settle; import 'explorer_test.dart' show act, settle;
import 'support/dav_server.dart'; import 'support/dav_server.dart';
import 'package:jj_mkvmaker/ui/sync_preview_view.dart' show keepOptionsTogether;
/// 95 · 96 · 97 · 100: Rsync 화면의 지우기 - 확인 창 · 미리 보기 · 실행이 같고, ⇄ 는 차례로 (← 는 지우지 않음) /// 95 · 96 · 97 · 100: Rsync 화면의 지우기 - 확인 창 · 미리 보기 · 실행이 같고, ⇄ 는 차례로 (← 는 지우지 않음)
void main() { void main() {
@ -78,7 +79,7 @@ void main() {
expect(find.textContaining('-avPog -u --delete'), findsNothing); expect(find.textContaining('-avPog -u --delete'), findsNothing);
expect(find.textContaining('rsync -avPog -u'), findsWidgets); expect(find.textContaining('rsync -avPog -u'), findsWidgets);
expect(runButton(tester).onPressed, isNotNull); 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 act(tester, () => tester.tap(find.widgetWithText(FilledButton, '실행')));
await settle(tester, () => find.byType(LinearProgressIndicator).evaluate().isEmpty && find.textContaining('rsync 끝').evaluate().isNotEmpty, await settle(tester, () => find.byType(LinearProgressIndicator).evaluate().isEmpty && find.textContaining('rsync 끝').evaluate().isNotEmpty,
rounds: 400); rounds: 400);
@ -101,12 +102,12 @@ void main() {
expect(find.textContaining('rsync -avPog --delete'), findsNothing); expect(find.textContaining('rsync -avPog --delete'), findsNothing);
expect(find.textContaining('− b.txt'), findsNothing); expect(find.textContaining('− b.txt'), findsNothing);
// 원본 파일 지우기 (이동 작업) → 그 작업의 옵션 (--delete) 으로 다시 보이고 다시 비교한다 // 원본 파일 지우기 (이동 작업) → 그 작업의 옵션 (--delete) 으로 다시 보이고 다시 비교한다
await tester.tap(find.text('원본 파일 지우기 (--remove-source-files)')); await tester.tap(find.text(keepOptionsTogether('원본 파일 지우기 (--remove-source-files)')));
await tester.pump(); await tester.pump();
expect(find.textContaining('rsync -avPog --delete --remove-source-files'), findsOneWidget); expect(find.textContaining('rsync -avPog --delete --remove-source-files'), findsOneWidget);
await waitCompare(tester); await waitCompare(tester);
expect(find.textContaining('− b.txt'), findsOneWidget); expect(find.textContaining('− b.txt'), findsOneWidget);
expect(find.text('받는 쪽에서 1개가 지워집니다 (--delete, 되돌릴 수 없음)'), findsOneWidget); expect(find.text(keepOptionsTogether('받는 쪽에서 1개가 지워집니다 (--delete, 되돌릴 수 없음)')), findsOneWidget);
expect(find.widgetWithText(FilledButton, '이동'), findsOneWidget); expect(find.widgetWithText(FilledButton, '이동'), findsOneWidget);
await tester.tap(find.text('취소')); await tester.tap(find.text('취소'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@ -118,7 +119,7 @@ void main() {
await open(tester); await open(tester);
await act(tester, () => tester.tap(find.text('좌 → 우'))); await act(tester, () => tester.tap(find.text('좌 → 우')));
await waitCompare(tester); 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.tap(find.byTooltip('다시 비교'));
await tester.pump(); await tester.pump();
expect(runButton(tester).onPressed, isNull); expect(runButton(tester).onPressed, isNull);

View File

@ -50,4 +50,16 @@ void main() {
expect(find.textContaining('(더 새 파일을 덮어씀)'), findsOneWidget); expect(find.textContaining('(더 새 파일을 덮어씀)'), findsOneWidget);
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');
});
} }

View File

@ -15,6 +15,7 @@ import 'package:jj_mkvmaker/ui/monitor_page.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'explorer_test.dart' show act, calm, settle; import 'explorer_test.dart' show act, calm, settle;
import 'package:jj_mkvmaker/ui/sync_preview_view.dart' show keepOptionsTogether;
/// 68 · 70 · 71 /// 68 · 70 · 71
void main() { void main() {
@ -204,7 +205,7 @@ void main() {
expect(tester.widget<FilledButton>(find.widgetWithText(FilledButton, '실행')).onPressed, isNull); expect(tester.widget<FilledButton>(find.widgetWithText(FilledButton, '실행')).onPressed, isNull);
await settle(tester, () => find.textContaining('비교하는 중').evaluate().isEmpty, rounds: 400); await settle(tester, () => find.textContaining('비교하는 중').evaluate().isEmpty, rounds: 400);
// 끝나면: 지울 목록 (빨강) · [실행 (1개 지움)] 켜짐 // 끝나면: 지울 목록 (빨강) · [실행 (1개 지움)] 켜짐
expect(find.text('받는 쪽에서 1개가 지워집니다 (--delete, 되돌릴 수 없음)'), findsOneWidget); expect(find.text(keepOptionsTogether('받는 쪽에서 1개가 지워집니다 (--delete, 되돌릴 수 없음)')), findsOneWidget);
expect(find.textContaining('− only_r.txt'), findsOneWidget); expect(find.textContaining('− only_r.txt'), findsOneWidget);
expect(tester.widget<FilledButton>(find.widgetWithText(FilledButton, '실행')).onPressed, isNotNull); expect(tester.widget<FilledButton>(find.widgetWithText(FilledButton, '실행')).onPressed, isNotNull);
await tester.tap(find.text('취소')); await tester.tap(find.text('취소'));