다국어 작은 것들 (61)

- 영어 복수형: 영어 글에서 수가 1 이면 단수 ("1 videos" → "1 video"). 11 · 21 · 1.5 는 그대로.
- 탐색기 복사 · 이동 확인: 문장 조각 + "복사할까요?" 를 잇던 것을 두 문장으로 (언어마다 어순이 깨졌음).
- MKV 기본 자막 트랙 · 플레이어가 먼저 켜는 자막: 늘 한국어 → 화면 언어 (목록에 없는 언어면 한국어).
- robocopy 진행: 종류 글 ("New File" 등) 이 아니라 탭 모양으로 파일 줄을 알아봐 일본어 · 중국어 Windows 에서도.
- Android 저장소 이름: 내장 · SD 카드는 앱 화면 언어로 (Kotlin 이 isPrimary 를 넘김), USB 등은 기기 이름 그대로.
- 기본 즐겨찾기 ("YouTube 구독" · 표시줄 · 기타): 한국어 열쇠로 저장하고 이름을 바꾸지 않았으면 화면 언어로
  (예전 판이 다른 언어로 저장한 기본 이름 포함). 바꾼 이름 · 사용자 항목은 그대로.
- i18n_small_test · sync_tools_test (일본어 · 중국어 robocopy 줄), 캡처 시험에 일본어 가로 첫 화면.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
묶음4
RyuJongJin 2026-10-10 02:24:45 +09:00
parent f33a4b3547
commit f429c59502
15 changed files with 201 additions and 22 deletions

View File

@ -340,6 +340,7 @@ class MainActivity : FlutterActivity() {
"path" to dir.absolutePath, "path" to dir.absolutePath,
"label" to (v.getDescription(this) ?: dir.name), "label" to (v.getDescription(this) ?: dir.name),
"removable" to v.isRemovable, "removable" to v.isRemovable,
"primary" to v.isPrimary,
) )
) )
} }

View File

@ -349,6 +349,8 @@
"{0}개 풀기": "Extract {0}", "{0}개 풀기": "Extract {0}",
"{0}개 항목을 {1}": "{0} items {1}", "{0}개 항목을 {1}": "{0} items {1}",
"{0}개 항목을 다음 폴더로 {1}\n{2}": "{0} items to this folder — {1}\n{2}", "{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}개 항목을 담았습니다. 넣을 폴더에서 [붙여넣기] 를 누르세요 ({1}).": "{0} items held. Press [Paste] in the destination folder ({1}).",
"{0}개 항목을 되돌렸습니다.": "Restored {0} item(s).", "{0}개 항목을 되돌렸습니다.": "Restored {0} item(s).",
"{0}개 항목을 지울까요? 되돌릴 수 없습니다.": "Delete {0} items? This cannot be undone.", "{0}개 항목을 지울까요? 되돌릴 수 없습니다.": "Delete {0} items? This cannot be undone.",

View File

@ -349,6 +349,8 @@
"{0}개 풀기": "{0} 個を展開", "{0}개 풀기": "{0} 個を展開",
"{0}개 항목을 {1}": "{0} 件の項目を{1}", "{0}개 항목을 {1}": "{0} 件の項目を{1}",
"{0}개 항목을 다음 폴더로 {1}\n{2}": "{0} 件の項目を次のフォルダーへ{1}\n{2}", "{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}개 항목을 담았습니다. 넣을 폴더에서 [붙여넣기] 를 누르세요 ({1}).": "{0} 件を保持しました。入れるフォルダーで [貼り付け] を押してください ({1})。",
"{0}개 항목을 되돌렸습니다.": "{0} 件を元に戻しました。", "{0}개 항목을 되돌렸습니다.": "{0} 件を元に戻しました。",
"{0}개 항목을 지울까요? 되돌릴 수 없습니다.": "{0} 件の項目を削除しますか? 元に戻せません。", "{0}개 항목을 지울까요? 되돌릴 수 없습니다.": "{0} 件の項目を削除しますか? 元に戻せません。",

View File

@ -349,6 +349,8 @@
"{0}개 풀기": "解压 {0} 个", "{0}개 풀기": "解压 {0} 个",
"{0}개 항목을 {1}": "{0} 个项目{1}", "{0}개 항목을 {1}": "{0} 个项目{1}",
"{0}개 항목을 다음 폴더로 {1}\n{2}": "将 {0} 个项目到以下文件夹 — {1}\n{2}", "{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}개 항목을 담았습니다. 넣을 폴더에서 [붙여넣기] 를 누르세요 ({1}).": "已暂存 {0} 个项目。请在目标文件夹中按 [粘贴] ({1})。",
"{0}개 항목을 되돌렸습니다.": "已恢复 {0} 个项目。", "{0}개 항목을 되돌렸습니다.": "已恢复 {0} 个项目。",
"{0}개 항목을 지울까요? 되돌릴 수 없습니다.": "删除 {0} 个项目吗? 此操作无法撤销。", "{0}개 항목을 지울까요? 되돌릴 수 없습니다.": "删除 {0} 个项目吗? 此操作无法撤销。",

View File

@ -77,4 +77,19 @@ void main() {
await t.runAsync(() => i18n.apply('ko', save: false)); 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));
});
}
} }

View File

@ -4,20 +4,38 @@ import 'dart:math' as math;
/// 즐겨찾기 항목 (주소) 또는 폴더 (children 이 있음) /// 즐겨찾기 항목 (주소) 또는 폴더 (children 이 있음)
class BookmarkNode { class BookmarkNode {
final String id; final String id;
String title;
/// 저장된 이름 그대로 (기본 즐겨찾기는 한국어 열쇠)
String rawTitle;
String? url; String? url;
final List<BookmarkNode>? children; final List<BookmarkNode>? children;
BookmarkNode.link(this.id, this.title, String this.url) : children = null; BookmarkNode.link(this.id, this.rawTitle, String this.url) : children = null;
BookmarkNode.folder(this.id, this.title, [List<BookmarkNode>? items]) BookmarkNode.folder(this.id, this.rawTitle, [List<BookmarkNode>? items])
: url = null, : url = null,
children = items ?? []; 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; bool get isFolder => children != null;
Map<String, Object?> toJson() => { Map<String, Object?> toJson() => {
'id': id, 'id': id,
'title': title, 'title': rawTitle,
if (url != null) 'url': url, if (url != null) 'url': url,
if (children != null) 'children': [for (final c in children!) c.toJson()], if (children != null) 'children': [for (final c in children!) c.toJson()],
}; };
@ -43,12 +61,13 @@ class BookmarkTree {
/// 처음 쓰는 경우 기본 즐겨찾기 /// 처음 쓰는 경우 기본 즐겨찾기
factory BookmarkTree.defaults() => BookmarkTree( factory BookmarkTree.defaults() => BookmarkTree(
BookmarkNode.folder(barId, tr('즐겨찾기 표시줄'), [ // 이름은 한국어 열쇠로 저장하고 보일 때 화면 언어로 (BookmarkNode.title)
BookmarkNode.folder(barId, '즐겨찾기 표시줄', [
BookmarkNode.link('b1', 'YouTube', 'https://www.youtube.com/'), 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.link('b3', 'OpenSubtitles', 'https://www.opensubtitles.com/'),
]), ]),
BookmarkNode.folder(otherId, tr('기타 즐겨찾기')), BookmarkNode.folder(otherId, '기타 즐겨찾기'),
); );
String newId() => 'n${++_seq}${math.Random().nextInt(1 << 20)}'; String newId() => 'n${++_seq}${math.Random().nextInt(1 << 20)}';

View File

@ -80,3 +80,13 @@ Language languageOf(String? token) {
// MKV 태그의 639-2/T 표기 (zho, deu 등)는 별칭에 포함되어 있음 // MKV 태그의 639-2/T 표기 (zho, deu 등)는 별칭에 포함되어 있음
return undetermined; 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);
}

View File

@ -1,5 +1,7 @@
import 'encode_options.dart'; import 'encode_options.dart';
import 'models.dart'; import 'models.dart';
import 'languages.dart';
import '../l10n/tr.dart';
/// MKV 에 그대로 복사 가능한 자막 코덱 /// MKV 에 그대로 복사 가능한 자막 코덱
const _copyableSubtitleCodecs = { const _copyableSubtitleCodecs = {
@ -13,8 +15,12 @@ String _externalCodec(String ext) => switch (ext) {
_ => 'srt', // srt, smi, sami, vtt _ => 'srt', // srt, smi, sami, vtt
}; };
/// 기본 자막으로 표시할 언어 (MKV 태그) /// 기본 자막으로 표시할 언어 (MKV 태그). 61: 화면 언어를 따른다 (예전에는 늘 한국어 'kor').
const preferredDefaultLanguage = 'kor'; /// 화면 언어가 자막 언어 목록에 없으면 한국어.
String get preferredDefaultLanguage {
final l = languageOf(uiLanguage);
return l == undetermined ? 'kor' : l.mkv;
}
/// 동영상 + 자막 → MKV 로 합치는 FFmpeg 인수. /// 동영상 + 자막 → MKV 로 합치는 FFmpeg 인수.
/// ///

View File

@ -197,9 +197,10 @@ class RobocopyOutput {
final _buf = StringBuffer(); final _buf = StringBuffer();
double? currentPercent; double? currentPercent;
static final _file = RegExp( /// 파일 줄: 탭 · 종류 · 탭 탭 · 크기 · 탭 · 경로. 61: 종류 글은 Windows 언어마다 다르므로
r'^\s*(New File|Newer|Older|Changed|Same|Tweaked|Modified|\*EXTRA File|새 파일|최신|이전|변경됨|같음)\s+[\d.]+\s*[kmgt]?\s+(.+)$', /// ("New File" · "새 파일" · "新しいファイル" · "新文件") 글이 아니라 탭 모양으로 알아본다.
caseSensitive: false); /// 종류가 * 로 시작하면 (*EXTRA File 등) 대상에만 있는 것이라 세지 않는다.
static final _file = RegExp(r'^\t+\s*([^\t\s\d][^\t]*?)\s*\t+\s*[\d.]+\s*[kmgt]?\s*\t(.+)$', caseSensitive: false);
List<String> feed(String chunk) { List<String> feed(String chunk) {
_buf.write(chunk); _buf.write(chunk);
@ -218,7 +219,7 @@ class RobocopyOutput {
continue; continue;
} }
final m = _file.firstMatch(raw); final m = _file.firstMatch(raw);
if (m != null && !m[1]!.toLowerCase().startsWith('*extra')) { if (m != null && !m[1]!.startsWith('*')) {
currentPercent = null; currentPercent = null;
done.add(m[2]!.trim()); done.add(m[2]!.trim());
} }

View File

@ -28,7 +28,26 @@ String tr(String ko) {
final _slot = RegExp(r'\{(\d+)\}'); final _slot = RegExp(r'\{(\d+)\}');
/// 자리 표시 ({0}, {1} …) 가 있는 글자: `trf('동영상 {0}개', [n])` /// 자리 표시 ({0}, {1} …) 가 있는 글자: `trf('동영상 {0}개', [n])`
String trf(String ko, List<Object?> args) => tr(ko).replaceAllMapped(_slot, (m) { String trf(String ko, List<Object?> args) {
final i = int.parse(m.group(1)!); final s = tr(ko).replaceAllMapped(_slot, (m) {
return i < args.length ? '${args[i]}' : m.group(0)!; 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');

View File

@ -36,7 +36,8 @@ class AndroidAccess {
final list = await _ch.invokeMethod<List<Object?>>('storageVolumes') ?? const []; final list = await _ch.invokeMethod<List<Object?>>('storageVolumes') ?? const [];
final out = [ final out = [
for (final v in list.cast<Map<Object?, Object?>>()) for (final v in list.cast<Map<Object?, Object?>>())
(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)); out.sort((a, b) => (a.$3 ? 1 : 0) - (b.$3 ? 1 : 0));
if (out.isNotEmpty) return out; if (out.isNotEmpty) return out;
@ -44,6 +45,14 @@ class AndroidAccess {
return [(await storageRoot(), tr('내장 저장소'), false)]; 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) /// 내장 저장소 맨 위 (예: /storage/emulated/0)
static Future<String> storageRoot() async { static Future<String> storageRoot() async {
try { try {

View File

@ -1727,7 +1727,8 @@ class _ExplorerPageState extends State<ExplorerPage> {
scrollable: true, scrollable: true,
title: Text(move ? tr('이동') : tr('복사')), title: Text(move ? tr('이동') : tr('복사')),
content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ 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)}' '\n${_namesOf(sources)}'
'${skippedHere.isEmpty ? '' : '\n${trf('이미 이 폴더에 있어 건너뜀: {0}', [_namesOf(skippedHere)])}'}' '${skippedHere.isEmpty ? '' : '\n${trf('이미 이 폴더에 있어 건너뜀: {0}', [_namesOf(skippedHere)])}'}'
'\n\n${trf('방법: {0}', [tr(method.label)])}' '\n\n${trf('방법: {0}', [tr(method.label)])}'

View File

@ -8,6 +8,7 @@ import 'package:path/path.dart' as p;
import 'package:window_manager/window_manager.dart'; import 'package:window_manager/window_manager.dart';
import '../app/app_controller.dart'; import '../app/app_controller.dart';
import '../core/languages.dart';
import '../core/playlist.dart'; import '../core/playlist.dart';
import '../core/srt.dart' show formatSrtTime; import '../core/srt.dart' show formatSrtTime;
import '../services/media_player.dart'; import '../services/media_player.dart';
@ -102,15 +103,15 @@ class _PlayerPageState extends State<PlayerPage> {
c.externalSubtitlesFor(pl.playlist[i]).then((s) async { c.externalSubtitlesFor(pl.playlist[i]).then((s) async {
if (!mounted) return; if (!mounted) return;
setState(() => _externalSubs = s); setState(() => _externalSubs = s);
// 켜진 자막이 없으면 하나를 켠다 (한국어 먼저, 없으면 첫 번째). mpv 가 같은 폴더 자막을 불러와도 // 켜진 자막이 없으면 하나를 켠다 (화면 언어의 자막 먼저 - 61, 없으면 첫 번째). mpv 가 같은 폴더 자막을 불러와도
// 켜지 않는 일이 있어 (Android) 그 뒤에 (1초) 확인한다. // 켜지 않는 일이 있어 (Android) 그 뒤에 (1초) 확인한다.
await Future<void>.delayed(const Duration(seconds: 1)); await Future<void>.delayed(const Duration(seconds: 1));
final st = pl.state.value; final st = pl.state.value;
if (!mounted || st.index != i || st.subtitleId != null) return; if (!mounted || st.index != i || st.subtitleId != null) return;
final all = _subtitleChoices(); final all = _subtitleChoices();
if (all.isEmpty) return; if (all.isEmpty) return;
final ko = RegExp(r'[._-](ko|kor)[._]|한국어|\(ko\)|\bkor\b', caseSensitive: false); final mine = preferredSubtitlePattern();
await _setSub(all.firstWhere((t) => ko.hasMatch(t.label) || ko.hasMatch(p.basename(t.file ?? '')), await _setSub(all.firstWhere((t) => mine.hasMatch(t.label) || mine.hasMatch(p.basename(t.file ?? '')),
orElse: () => all.first)); orElse: () => all.first));
}); });
} }

79
test/i18n_small_test.dart Normal file
View File

@ -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'], '즐겨찾기 표시줄');
});
}

View File

@ -97,6 +97,18 @@ void main() {
expect(o.currentPercent, 0.45); 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']); 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('실제 복사', () { group('실제 복사', () {