diff --git a/android/app/src/main/kotlin/com/jj/jj_mkvmaker/MainActivity.kt b/android/app/src/main/kotlin/com/jj/jj_mkvmaker/MainActivity.kt index 070e073..bca6eff 100644 --- a/android/app/src/main/kotlin/com/jj/jj_mkvmaker/MainActivity.kt +++ b/android/app/src/main/kotlin/com/jj/jj_mkvmaker/MainActivity.kt @@ -340,6 +340,7 @@ class MainActivity : FlutterActivity() { "path" to dir.absolutePath, "label" to (v.getDescription(this) ?: dir.name), "removable" to v.isRemovable, + "primary" to v.isPrimary, ) ) } diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 2475f9f..d094298 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -349,6 +349,8 @@ "{0}개 풀기": "Extract {0}", "{0}개 항목을 {1}": "{0} items {1}", "{0}개 항목을 다음 폴더로 {1}\n{2}": "{0} items to this folder — {1}\n{2}", + "{0}개 항목을 다음 폴더로 복사할까요?\n{1}": "Copy {0} items to this folder?\n{1}", + "{0}개 항목을 다음 폴더로 옮길까요?\n{1}": "Move {0} items to this folder?\n{1}", "{0}개 항목을 담았습니다. 넣을 폴더에서 [붙여넣기] 를 누르세요 ({1}).": "{0} items held. Press [Paste] in the destination folder ({1}).", "{0}개 항목을 되돌렸습니다.": "Restored {0} item(s).", "{0}개 항목을 지울까요? 되돌릴 수 없습니다.": "Delete {0} items? This cannot be undone.", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 2f788f8..6cc82e4 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -349,6 +349,8 @@ "{0}개 풀기": "{0} 個を展開", "{0}개 항목을 {1}": "{0} 件の項目を{1}", "{0}개 항목을 다음 폴더로 {1}\n{2}": "{0} 件の項目を次のフォルダーへ{1}\n{2}", + "{0}개 항목을 다음 폴더로 복사할까요?\n{1}": "{0} 件の項目を次のフォルダーへコピーしますか?\n{1}", + "{0}개 항목을 다음 폴더로 옮길까요?\n{1}": "{0} 件の項目を次のフォルダーへ移動しますか?\n{1}", "{0}개 항목을 담았습니다. 넣을 폴더에서 [붙여넣기] 를 누르세요 ({1}).": "{0} 件を保持しました。入れるフォルダーで [貼り付け] を押してください ({1})。", "{0}개 항목을 되돌렸습니다.": "{0} 件を元に戻しました。", "{0}개 항목을 지울까요? 되돌릴 수 없습니다.": "{0} 件の項目を削除しますか? 元に戻せません。", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index ad1e4a1..5f9b600 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -349,6 +349,8 @@ "{0}개 풀기": "解压 {0} 个", "{0}개 항목을 {1}": "{0} 个项目{1}", "{0}개 항목을 다음 폴더로 {1}\n{2}": "将 {0} 个项目到以下文件夹 — {1}\n{2}", + "{0}개 항목을 다음 폴더로 복사할까요?\n{1}": "要将 {0} 个项目复制到以下文件夹吗?\n{1}", + "{0}개 항목을 다음 폴더로 옮길까요?\n{1}": "要将 {0} 个项目移动到以下文件夹吗?\n{1}", "{0}개 항목을 담았습니다. 넣을 폴더에서 [붙여넣기] 를 누르세요 ({1}).": "已暂存 {0} 个项目。请在目标文件夹中按 [粘贴] ({1})。", "{0}개 항목을 되돌렸습니다.": "已恢复 {0} 个项目。", "{0}개 항목을 지울까요? 되돌릴 수 없습니다.": "删除 {0} 个项目吗? 此操作无法撤销。", diff --git a/integration_test/windows_i18n_shot_test.dart b/integration_test/windows_i18n_shot_test.dart index 028acb7..2359cd4 100644 --- a/integration_test/windows_i18n_shot_test.dart +++ b/integration_test/windows_i18n_shot_test.dart @@ -77,4 +77,19 @@ void main() { await t.runAsync(() => i18n.apply('ko', save: false)); }); } + + // 61: 일본어 가로 (태블릿 크기) 에서 맨 위 버튼 글이 잘리는지 - 첫 화면 (MKV) 과 각 화면의 맨 위 줄 + for (final size in [const Size(1280, 800), const Size(1138, 712), const Size(960, 600)]) { + testWidgets('61: 일본어 가로 ${size.width.toInt()}x${size.height.toInt()} - 맨 위 버튼', (t) async { + t.view.physicalSize = size; + t.view.devicePixelRatio = 1; + addTearDown(t.view.reset); + final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + await t.runAsync(() => i18n.apply('ja', save: false)); + await t.runAsync(() => t.pumpWidget(RepaintBoundary(key: shotKey, child: JjMkvMakerApp(controller: c, navigatorKey: navKey)))); + await settle(t, 20); + await shot(t, 'i18n_ja_home_${size.width.toInt()}'); + await t.runAsync(() => i18n.apply('ko', save: false)); + }); + } } diff --git a/lib/core/bookmarks.dart b/lib/core/bookmarks.dart index c5192f3..8cea67d 100644 --- a/lib/core/bookmarks.dart +++ b/lib/core/bookmarks.dart @@ -4,20 +4,38 @@ import 'dart:math' as math; /// 즐겨찾기 항목 (주소) 또는 폴더 (children 이 있음) class BookmarkNode { final String id; - String title; + + /// 저장된 이름 그대로 (기본 즐겨찾기는 한국어 열쇠) + String rawTitle; String? url; final List? children; - BookmarkNode.link(this.id, this.title, String this.url) : children = null; - BookmarkNode.folder(this.id, this.title, [List? items]) + BookmarkNode.link(this.id, this.rawTitle, String this.url) : children = null; + BookmarkNode.folder(this.id, this.rawTitle, [List? items]) : url = null, children = items ?? []; + /// 화면에 보일 이름. 61: 기본 즐겨찾기 ("YouTube 구독" · 표시줄 · 기타) 는 이름을 바꾸지 않았으면 지금 화면 언어로 + /// (처음 켤 때의 언어로 저장돼 그 뒤 언어를 바꿔도 그대로였다). 사용자가 바꾼 이름은 그대로. + String get title { + final names = _defaultNames[id]; + return names != null && names.contains(rawTitle) ? tr(names.first) : rawTitle; + } + + set title(String v) => rawTitle = v; + + /// 기본 즐겨찾기의 이름: 한국어 열쇠 (맨 앞) 와 예전 판이 저장했을 수 있는 번역 (en · ja · zh-Hans) + static const _defaultNames = { + 'b2': ['YouTube 구독', 'YouTube subscriptions', 'YouTube 登録チャンネル', 'YouTube 订阅'], + BookmarkTree.barId: ['즐겨찾기 표시줄', 'Bookmarks bar', 'ブックマークバー', '书签栏'], + BookmarkTree.otherId: ['기타 즐겨찾기', 'Other bookmarks', 'その他のブックマーク', '其他书签'], + }; + bool get isFolder => children != null; Map toJson() => { 'id': id, - 'title': title, + 'title': rawTitle, if (url != null) 'url': url, if (children != null) 'children': [for (final c in children!) c.toJson()], }; @@ -43,12 +61,13 @@ class BookmarkTree { /// 처음 쓰는 경우 기본 즐겨찾기 factory BookmarkTree.defaults() => BookmarkTree( - BookmarkNode.folder(barId, tr('즐겨찾기 표시줄'), [ + // 이름은 한국어 열쇠로 저장하고 보일 때 화면 언어로 (BookmarkNode.title) + BookmarkNode.folder(barId, '즐겨찾기 표시줄', [ BookmarkNode.link('b1', 'YouTube', 'https://www.youtube.com/'), - BookmarkNode.link('b2', tr('YouTube 구독'), 'https://www.youtube.com/feed/subscriptions'), + BookmarkNode.link('b2', 'YouTube 구독', 'https://www.youtube.com/feed/subscriptions'), BookmarkNode.link('b3', 'OpenSubtitles', 'https://www.opensubtitles.com/'), ]), - BookmarkNode.folder(otherId, tr('기타 즐겨찾기')), + BookmarkNode.folder(otherId, '기타 즐겨찾기'), ); String newId() => 'n${++_seq}${math.Random().nextInt(1 << 20)}'; diff --git a/lib/core/languages.dart b/lib/core/languages.dart index b9e9f09..dcd4d39 100644 --- a/lib/core/languages.dart +++ b/lib/core/languages.dart @@ -80,3 +80,13 @@ Language languageOf(String? token) { // MKV 태그의 639-2/T 표기 (zho, deu 등)는 별칭에 포함되어 있음 return undetermined; } + +/// 61: 플레이어가 먼저 켤 자막을 알아보는 글 (화면 언어의 자막: 파일 이름의 코드 · 트랙 이름). 예전에는 늘 한국어. +/// 화면 언어가 자막 언어 목록에 없으면 한국어. +RegExp preferredSubtitlePattern([String? ui]) { + var l = languageOf(ui ?? uiLanguage); + if (l == undetermined) l = languageOf('ko'); + final codes = {l.code.toLowerCase(), l.mkv, ...l.aliases.where((a) => RegExp(r'^[a-z-]+$').hasMatch(a))}.map(RegExp.escape).join('|'); + final names = {l.koName, l.name, ...l.aliases.where((a) => !RegExp(r'^[a-z-]+$').hasMatch(a))}.map(RegExp.escape).join('|'); + return RegExp('[._-]($codes)[._]|\\(($codes)\\)|\\b(${l.mkv})\\b|$names', caseSensitive: false); +} diff --git a/lib/core/mkv_command_builder.dart b/lib/core/mkv_command_builder.dart index a3816ff..5c3f21f 100644 --- a/lib/core/mkv_command_builder.dart +++ b/lib/core/mkv_command_builder.dart @@ -1,5 +1,7 @@ import 'encode_options.dart'; import 'models.dart'; +import 'languages.dart'; +import '../l10n/tr.dart'; /// MKV 에 그대로 복사 가능한 자막 코덱 const _copyableSubtitleCodecs = { @@ -13,8 +15,12 @@ String _externalCodec(String ext) => switch (ext) { _ => 'srt', // srt, smi, sami, vtt }; -/// 기본 자막으로 표시할 언어 (MKV 태그) -const preferredDefaultLanguage = 'kor'; +/// 기본 자막으로 표시할 언어 (MKV 태그). 61: 화면 언어를 따른다 (예전에는 늘 한국어 'kor'). +/// 화면 언어가 자막 언어 목록에 없으면 한국어. +String get preferredDefaultLanguage { + final l = languageOf(uiLanguage); + return l == undetermined ? 'kor' : l.mkv; +} /// 동영상 + 자막 → MKV 로 합치는 FFmpeg 인수. /// diff --git a/lib/core/sync_tools.dart b/lib/core/sync_tools.dart index 7266678..c7e1811 100644 --- a/lib/core/sync_tools.dart +++ b/lib/core/sync_tools.dart @@ -197,9 +197,10 @@ class RobocopyOutput { final _buf = StringBuffer(); double? currentPercent; - static final _file = RegExp( - r'^\s*(New File|Newer|Older|Changed|Same|Tweaked|Modified|\*EXTRA File|새 파일|최신|이전|변경됨|같음)\s+[\d.]+\s*[kmgt]?\s+(.+)$', - caseSensitive: false); + /// 파일 줄: 탭 · 종류 · 탭 탭 · 크기 · 탭 · 경로. 61: 종류 글은 Windows 언어마다 다르므로 + /// ("New File" · "새 파일" · "新しいファイル" · "新文件") 글이 아니라 탭 모양으로 알아본다. + /// 종류가 * 로 시작하면 (*EXTRA File 등) 대상에만 있는 것이라 세지 않는다. + static final _file = RegExp(r'^\t+\s*([^\t\s\d][^\t]*?)\s*\t+\s*[\d.]+\s*[kmgt]?\s*\t(.+)$', caseSensitive: false); List feed(String chunk) { _buf.write(chunk); @@ -218,7 +219,7 @@ class RobocopyOutput { continue; } final m = _file.firstMatch(raw); - if (m != null && !m[1]!.toLowerCase().startsWith('*extra')) { + if (m != null && !m[1]!.startsWith('*')) { currentPercent = null; done.add(m[2]!.trim()); } diff --git a/lib/l10n/tr.dart b/lib/l10n/tr.dart index f7ffe6e..77b8833 100644 --- a/lib/l10n/tr.dart +++ b/lib/l10n/tr.dart @@ -28,7 +28,26 @@ String tr(String ko) { final _slot = RegExp(r'\{(\d+)\}'); /// 자리 표시 ({0}, {1} …) 가 있는 글자: `trf('동영상 {0}개', [n])` -String trf(String ko, List args) => tr(ko).replaceAllMapped(_slot, (m) { - final i = int.parse(m.group(1)!); - return i < args.length ? '${args[i]}' : m.group(0)!; - }); +String trf(String ko, List args) { + final s = tr(ko).replaceAllMapped(_slot, (m) { + final i = int.parse(m.group(1)!); + return i < args.length ? '${args[i]}' : m.group(0)!; + }); + // 61: 영어 글이면 "1 videos" → "1 video" (영어 사전 · 영어로 대신 보인 글) + final english = _lang == 'en' || (_lang != 'ko' && !_dict.containsKey(ko) && _fallback.containsKey(ko)); + return english ? englishSingular(s) : s; +} + +/// 수가 1 인 영어 복수 명사를 단수로 ("1 videos" · "(1 files)" → "1 video" · "(1 file)"). 11 · 21 · 1.5 는 그대로. +String englishSingular(String s) => s.replaceAllMapped(_one, (m) => '${m[1]}1 ${_singularOf[m[2]!]}'); + +const _singularOf = { + 'videos': 'video', 'files': 'file', 'items': 'item', 'subtitles': 'subtitle', 'downloads': 'download', + 'images': 'image', 'lines': 'line', 'results': 'result', 'characters': 'character', 'syncs': 'sync', + 'MKVs': 'MKV', 'links': 'link', 'folders': 'folder', 'pages': 'page', 'times': 'time', 'pairs': 'pair', + 'servers': 'server', 'tracks': 'track', 'languages': 'language', 'models': 'model', 'bookmarks': 'bookmark', + 'errors': 'error', 'seconds': 'second', 'minutes': 'minute', 'hours': 'hour', 'days': 'day', 'tasks': 'task', + 'copies': 'copy', 'entries': 'entry', 'episodes': 'episode', 'changes': 'change', 'components': 'component', +}; + +final _one = RegExp('(^|[^\\d.,])1 (${_singularOf.keys.join('|')})\\b'); diff --git a/lib/platform/android/android_storage.dart b/lib/platform/android/android_storage.dart index 5c03664..a98bc41 100644 --- a/lib/platform/android/android_storage.dart +++ b/lib/platform/android/android_storage.dart @@ -36,7 +36,8 @@ class AndroidAccess { final list = await _ch.invokeMethod>('storageVolumes') ?? const []; final out = [ for (final v in list.cast>()) - (v['path'] as String, v['label'] as String? ?? '', v['removable'] == true), + (v['path'] as String, volumeLabel(v['label'] as String? ?? '', primary: v['primary'] == true, removable: v['removable'] == true), + v['removable'] == true), ]; out.sort((a, b) => (a.$3 ? 1 : 0) - (b.$3 ? 1 : 0)); if (out.isNotEmpty) return out; @@ -44,6 +45,14 @@ class AndroidAccess { return [(await storageRoot(), tr('내장 저장소'), false)]; } + /// 61: 저장소 이름을 앱 화면 언어로. 기기가 주는 이름 ("디바이스 저장공간" · "SD 카드") 은 기기 언어라 + /// 내장 저장소 · SD 카드는 앱의 글로 바꾸고, 그 밖 (USB 메모리 등) 은 기기 이름 그대로. + static String volumeLabel(String device, {required bool primary, required bool removable}) { + if (primary) return tr('내장 저장소'); + if (removable && !device.toUpperCase().contains('USB')) return tr('SD 카드'); + return device; + } + /// 내장 저장소 맨 위 (예: /storage/emulated/0) static Future storageRoot() async { try { diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index 5b8bc19..8874673 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -1727,7 +1727,8 @@ class _ExplorerPageState extends State { scrollable: true, title: Text(move ? tr('이동') : tr('복사')), content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('${trf('{0}개 항목을 다음 폴더로 {1}\n{2}', [sources.length, move ? tr('옮길까요?') : tr('복사할까요?'), vDisplay(dest)])}' + // 61: 문장 조각을 이어 붙이지 않는다 (언어마다 어순이 달라 "…로 {복사할까요?}" 가 깨졌다) + Text('${move ? trf('{0}개 항목을 다음 폴더로 옮길까요?\n{1}', [sources.length, vDisplay(dest)]) : trf('{0}개 항목을 다음 폴더로 복사할까요?\n{1}', [sources.length, vDisplay(dest)])}' '\n${_namesOf(sources)}' '${skippedHere.isEmpty ? '' : '\n${trf('이미 이 폴더에 있어 건너뜀: {0}', [_namesOf(skippedHere)])}'}' '\n\n${trf('방법: {0}', [tr(method.label)])}' diff --git a/lib/ui/player_page.dart b/lib/ui/player_page.dart index a74bc8b..0e920e6 100644 --- a/lib/ui/player_page.dart +++ b/lib/ui/player_page.dart @@ -8,6 +8,7 @@ import 'package:path/path.dart' as p; import 'package:window_manager/window_manager.dart'; import '../app/app_controller.dart'; +import '../core/languages.dart'; import '../core/playlist.dart'; import '../core/srt.dart' show formatSrtTime; import '../services/media_player.dart'; @@ -102,15 +103,15 @@ class _PlayerPageState extends State { c.externalSubtitlesFor(pl.playlist[i]).then((s) async { if (!mounted) return; setState(() => _externalSubs = s); - // 켜진 자막이 없으면 하나를 켠다 (한국어 먼저, 없으면 첫 번째). mpv 가 같은 폴더 자막을 불러와도 + // 켜진 자막이 없으면 하나를 켠다 (화면 언어의 자막 먼저 - 61, 없으면 첫 번째). mpv 가 같은 폴더 자막을 불러와도 // 켜지 않는 일이 있어 (Android) 그 뒤에 (1초) 확인한다. await Future.delayed(const Duration(seconds: 1)); final st = pl.state.value; if (!mounted || st.index != i || st.subtitleId != null) return; final all = _subtitleChoices(); if (all.isEmpty) return; - final ko = RegExp(r'[._-](ko|kor)[._]|한국어|\(ko\)|\bkor\b', caseSensitive: false); - await _setSub(all.firstWhere((t) => ko.hasMatch(t.label) || ko.hasMatch(p.basename(t.file ?? '')), + final mine = preferredSubtitlePattern(); + await _setSub(all.firstWhere((t) => mine.hasMatch(t.label) || mine.hasMatch(p.basename(t.file ?? '')), orElse: () => all.first)); }); } diff --git a/test/i18n_small_test.dart b/test/i18n_small_test.dart new file mode 100644 index 0000000..d09ab7c --- /dev/null +++ b/test/i18n_small_test.dart @@ -0,0 +1,79 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/app/i18n_controller.dart'; +import 'package:jj_mkvmaker/core/bookmarks.dart'; +import 'package:jj_mkvmaker/core/languages.dart'; +import 'package:jj_mkvmaker/core/mkv_command_builder.dart'; +import 'package:jj_mkvmaker/l10n/tr.dart'; +import 'package:jj_mkvmaker/platform/android/android_storage.dart'; + +/// 61: 다국어 작은 것들 +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + tearDown(() => i18n.apply('ko', save: false)); + + test('영어 복수형: 수가 1 이면 단수 ("1 videos" → "1 video"), 11 · 21 · 1.5 는 그대로', () { + expect(englishSingular('Added 1 videos to the list.'), 'Added 1 video to the list.'); + expect(englishSingular('(1 files)'), '(1 file)'); + expect(englishSingular('1 copies · 1 entries'), '1 copy · 1 entry'); + expect(englishSingular('11 videos · 21 files · 1.1 videos · 0,1 items'), '11 videos · 21 files · 1.1 videos · 0,1 items'); + expect(englishSingular('2 videos'), '2 videos'); + }); + + test('영어 화면에서 trf 가 단수를 쓴다 (한국어 · 일본어는 그대로)', () async { + await i18n.apply('en', save: false); + expect(trf('{0}개 항목을 다음 폴더로 옮길까요?\n{1}', [1, 'D:']), 'Move 1 item to this folder?\nD:'); + expect(trf('{0}개 항목을 다음 폴더로 옮길까요?\n{1}', [3, 'D:']), 'Move 3 items to this folder?\nD:'); + await i18n.apply('ja', save: false); + expect(trf('{0}개 항목을 다음 폴더로 복사할까요?\n{1}', [1, 'D:']), contains('1 件')); + }); + + test('MKV 기본 자막 · 플레이어가 먼저 켤 자막은 화면 언어 (목록에 없는 언어면 한국어)', () async { + expect(preferredDefaultLanguage, 'kor'); + await i18n.apply('ja', save: false); + expect(preferredDefaultLanguage, 'jpn'); + final ja = preferredSubtitlePattern(); + expect(ja.hasMatch('movie.ja.srt'), isTrue); + expect(ja.hasMatch('movie.jpn.srt'), isTrue); + expect(ja.hasMatch('日本語'), isTrue, reason: '트랙 이름 (화면 언어로 된 언어 이름)'); + expect(ja.hasMatch('movie.ko.srt'), isFalse); + await i18n.apply('en', save: false); + expect(preferredDefaultLanguage, 'eng'); + expect(preferredSubtitlePattern().hasMatch('Movie.en.srt'), isTrue); + expect(preferredSubtitlePattern().hasMatch('Movie.kor.srt'), isFalse); + // 한국어 (예전 그대로) + final ko = preferredSubtitlePattern('ko'); + for (final s in ['a.ko.srt', 'a.kor.ass', 'a (ko).srt', '한국어']) { + expect(ko.hasMatch(s), isTrue, reason: s); + } + expect(preferredSubtitlePattern('xx').hasMatch('a.ko.srt'), isTrue, reason: '모르는 언어면 한국어'); + }); + + test('Android 저장소 이름: 내장 · SD 카드는 앱 화면 언어로, USB 등은 기기 이름 그대로', () async { + await i18n.apply('en', save: false); + expect(AndroidAccess.volumeLabel('디바이스 저장공간', primary: true, removable: false), 'Internal storage'); + expect(AndroidAccess.volumeLabel('SD 카드', primary: false, removable: true), 'SD card'); + expect(AndroidAccess.volumeLabel('USB 저장소', primary: false, removable: true), 'USB 저장소'); + }); + + test('기본 즐겨찾기 이름은 바꾸지 않았으면 화면 언어로 (예전 판이 다른 언어로 저장했어도), 바꾼 이름은 그대로', () async { + final t = BookmarkTree.defaults(); + expect(t.bar.children![1].title, 'YouTube 구독'); + await i18n.apply('en', save: false); + expect(t.bar.children![1].title, 'YouTube subscriptions'); + expect(t.bar.title, 'Bookmarks bar'); + // 예전 판이 일본어로 저장해 둔 기본 이름 + final old = BookmarkTree.fromJson({ + 'bar': {'id': 'bar', 'title': 'ブックマークバー', 'children': [ + {'id': 'b2', 'title': 'YouTube 登録チャンネル', 'url': 'https://www.youtube.com/feed/subscriptions'}, + {'id': 'n1', 'title': 'YouTube 구독', 'url': 'https://x'}, + ]}, + 'other': {'id': 'other', 'title': '내 것', 'children': []}, + }); + expect(old.bar.title, 'Bookmarks bar'); + expect(old.bar.children![0].title, 'YouTube subscriptions'); + expect(old.bar.children![1].title, 'YouTube 구독', reason: '기본이 아닌 항목 (사용자가 만든 것) 은 그대로'); + expect(old.other.title, '내 것', reason: '바꾼 이름은 그대로'); + // 저장은 원래 이름 그대로 (언어를 바꿔도 저장 파일이 바뀌지 않게) + expect((t.toJson()['bar'] as Map)['title'], '즐겨찾기 표시줄'); + }); +} diff --git a/test/sync_tools_test.dart b/test/sync_tools_test.dart index f39fe11..0aeb8fd 100644 --- a/test/sync_tools_test.dart +++ b/test/sync_tools_test.dart @@ -97,6 +97,18 @@ void main() { expect(o.currentPercent, 0.45); expect(o.feed('100% \n\t Newer \t\t 200\tC:\\t\\src\\b.txt\n\t*EXTRA File \t\t 1\tC:\\x\n'), [r'C:\t\src\b.txt']); }); + + test('61: robocopy 파일 줄은 Windows 언어와 상관없이 (일본어 · 중국어 · 한국어 종류 글)', () { + final o = RobocopyOutput(); + expect(o.feed('\t 新しいファイル \t\t 3000000\tC:\\t\\a b.txt\r\n 34% \r\n'), [r'C:\t\a b.txt']); + expect(o.currentPercent, 0.34); + expect(o.feed('\t 新文件 \t\t 1\tC:\\t\\c.txt\n\t 새 파일 \t\t 12.5 m\tC:\\t\\d.mkv\n'), [r'C:\t\c.txt', r'C:\t\d.mkv']); + // 대상에만 있는 것 (* 로 시작) · 폴더 줄 (종류 없이 수 · 경로) · 오류 줄은 세지 않는다 + expect( + o.feed('\t*追加ファイル \t\t 1\tC:\\x\n\t\t\t\t 2\tC:\\t\\sub\\\n' + '2026/10/10 01:00:00 ERROR 5 (0x00000005) Copying File C:\\t\\e.txt\n'), + isEmpty); + }); }); group('실제 복사', () {