diff --git a/integration_test/windows_mkvend_shot_test.dart b/integration_test/windows_mkvend_shot_test.dart new file mode 100644 index 0000000..0d5caae --- /dev/null +++ b/integration_test/windows_mkvend_shot_test.dart @@ -0,0 +1,137 @@ +import 'dart:async'; +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/core/models.dart'; +import 'package:jj_mkvmaker/l10n/tr.dart'; +import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; +import 'package:jj_mkvmaker/services/media_tool.dart'; +import 'package:jj_mkvmaker/services/platform_services.dart'; +import 'package:jj_mkvmaker/ui/home_page.dart'; +import 'package:jj_mkvmaker/ui/theme.dart'; +import 'package:path/path.dart' as p; + +import 'support/temp_dir.dart'; + +/// 시험이 끝내라고 할 때까지 FFmpeg 이 도는 척 (끝나면 임시 출력 파일을 씀) - test/batch_test.dart 의 _EndTool 과 같음 +class _EndTool implements MediaTool { + final runs = >{}; + @override + Future version() async => 'ffmpeg test'; + @override + Future> encoders() async => {'libx264'}; + @override + Future probe(String path) async => const MediaInfo(duration: Duration(seconds: 1)); + @override + Future runFfmpeg(List args, {Duration? duration, ProgressCallback? onProgress, Object? tag}) async { + final c = runs[tag] = Completer(); + try { + await c.future; + } finally { + runs.remove(tag); + } + File(args.last).writeAsStringSync('NEW'); + } + + void _stop(Completer? c) { + if (c != null && !c.isCompleted) c.completeError(MediaToolException('사용자가 취소했습니다.')); + } + + void finish(Object tag) => runs[tag]!.complete(); + @override + void cancel() => runs.values.toList().forEach(_stop); + @override + void cancelTagged(Object tag) => _stop(runs[tag]); +} + +/// 262 (관리자 지시 10/21): MKV 묶음에서 한 동영상이 끝나는 바로 그때 - 그 줄의 ■ 자리에 오는 ⟳ 는 묶음이 끝날 때까지 눌리지 않고 +/// (✕ 도 밀려오지 않음), 끝나면 둘 다 성공. ko · en · ja × 412 · 960 (가짜 FFmpeg, 시험 폴더 안) +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + final out = Platform.environment['JJ_SHOT_DIR'] ?? ''; + + for (final lang in ['ko', 'en', 'ja']) { + for (final (w, h) in [(412, 900), (960, 600)]) { + testWidgets('262 MKV 끝나는 순간 $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_mkvend_'); + appTempInto(tmp); + final files = [for (final n in ['episode_01', 'episode_02']) (File(p.join(tmp.path, '$n.mp4'))..writeAsBytesSync([0])).path]; + final tool = _EndTool(); + final c = AppController(PlatformServices(mediaTool: tool, storage: DesktopStorageService())); + late Future building; + await t.runAsync(() async { + await c.init(); + c.settings.maxParallelJobs = 1; + await c.addVideos(files); + building = c.buildVideos([...c.videos]); + for (var i = 0; i < 200 && tool.runs.isEmpty; i++) { + await Future.delayed(const Duration(milliseconds: 10)); + } + }); + final [a, b] = c.videos; + final shotKey = GlobalKey(); + await t.pumpWidget(RepaintBoundary( + key: shotKey, + child: MaterialApp(theme: buildTheme(), debugShowCheckedModeBanner: false, home: HomePage(c: c)), + )); + Future settle([int n = 6]) async { + for (var i = 0; i < n; i++) { + await t.runAsync(() => Future.delayed(const Duration(milliseconds: 50))); + await t.pump(); + } + } + + Future shot(String name) async { + await settle(2); + expect(t.takeException(), isNull, reason: '$name 넘침'); + if (out.isEmpty) return; + final r = shotKey.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final img = await t.runAsync(() => r.toImage()); + final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png)); + Directory(out).createSync(recursive: true); + File(p.join(out, 'mkvend_${name}_${lang}_$w.png')).writeAsBytesSync(bytes!.buffer.asUint8List()); + } + + await settle(); + final stop = find.byTooltip(tr('이 동영상만 취소')); + expect(stop, findsWidgets, reason: '도는 줄의 ■'); + final stopAt = t.getCenter(stop.first); + await shot('1_running'); + // a 가 끝나고 b 가 시작 - a 의 ■ 자리를 누른다 (손가락이 ■ 를 누르려던 것) + await t.runAsync(() async { + tool.finish(a); + for (var i = 0; i < 200 && !tool.runs.containsKey(b); i++) { + await Future.delayed(const Duration(milliseconds: 10)); + } + }); + await settle(); + expect(a.status, JobStatus.done); + await t.tapAt(stopAt); + await settle(); + expect(a.status, JobStatus.done, reason: '⟳ 로 대기가 되지 않음'); + expect(c.videos, contains(a), reason: '✕ 로 빠지지 않음'); + await shot('2_a_done_b_running'); + await t.runAsync(() async { + tool.finish(b); + await building; + }); + await settle(); + expect((a.status, b.status), (JobStatus.done, JobStatus.done)); + await shot('3_all_done'); + await t.pumpWidget(const SizedBox()); + await t.pump(const Duration(seconds: 2)); + }); + } + } +} diff --git a/lib/l10n/tr.dart b/lib/l10n/tr.dart index a6d789c..c2b0c17 100644 --- a/lib/l10n/tr.dart +++ b/lib/l10n/tr.dart @@ -12,6 +12,10 @@ Map _fallback = const {}; /// 지금 화면 언어 코드 (ko · en · ja · zh-Hans …) String get uiLanguage => _lang; +/// 문장 여럿을 한 줄로 잇는다 - 한국어 · 영어는 빈칸, 일본어 · 중국어는 붙여서 (。 뒤에 빈칸을 두지 않음, 관리자 지시 10/21) +String joinSentences(Iterable parts) => + parts.where((x) => x.isNotEmpty).join(_lang.startsWith('ja') || _lang.startsWith('zh') ? '' : ' '); + /// 화면 언어와 사전을 바꾼다. [fallback] 은 사전에 없는 글자를 찾을 사전 (보통 영어). void setTranslations(String lang, Map dict, [Map fallback = const {}]) { _lang = lang; diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index dbcea2a..19672de 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -2881,18 +2881,18 @@ class _ExplorerPageState extends State { notDeleted.add('${vBasename(x)}: ${e is FileSystemException ? (e.osError?.message ?? e.message) : e}'); } } - _snack([ + _snack(joinSentences([ if (tooLong.length > notDeleted.length) trf('{0}개를 영구 삭제했습니다.', [tooLong.length - notDeleted.length]), if (notDeleted.isNotEmpty) trf('지우지 못함: {0}', [notDeleted.take(3).join(' · ')]), - ].join(' ')); + ])); } } else if (!permanent && mounted) { - final text = [ + final text = joinSentences([ if (recycled > 0) trf('{0}개 항목을 휴지통으로 보냈습니다.', [recycled]), // 휴지통보다 커서 Windows 가 물었고 사용자가 영구 삭제를 골랐다 if (nuked > 0) trf('{0}개는 휴지통에 들어가지 않아 영구 삭제했습니다 (Windows 가 묻고 고른 대로).', [nuked]), if (kept > 0) trf('{0}개는 지우지 않았습니다 (취소).', [kept]), - ].join(' '); + ]); // 148: 휴지통으로 보낸 것은 바로 [되돌리기] ScaffoldMessenger.of(context).showSnackBar(SnackBar( duration: const Duration(seconds: 10), @@ -2973,10 +2973,10 @@ class _ExplorerPageState extends State { for (final x in _panes) { x.changed(); } - _snack([ + _snack(joinSentences([ if (back > 0) trf('{0}개 항목을 되돌렸습니다.', [back]), if (failed.isNotEmpty) trf('되돌리지 못함: {0}', [failed.take(3).join(' · ')]), - ].join(' ')); + ])); } /// 다른 창의 지금 폴더로 복사 · 이동 (진행 창 · 취소) @@ -3315,7 +3315,7 @@ class _ExplorerPageState extends State { ? tr('취소했습니다.') : error != null ? trf('끝나지 못했습니다: {0}', [error]) - : [ + : joinSentences([ trf('{0}개 항목을 {1}', [job.made.length, move ? tr('옮겼습니다.') : tr('복사했습니다.')]), // 관리자 지시 (10/21, 15 찾음 F3): 뒤 조각도 문장으로 쓰고 빈칸으로 잇는다 (예전에는 "…옮겼습니다. · 1개는 …" 처럼 마침표 뒤에 " · ") // 264: 작업 기록에만 있던 것도 알림에 (폴더를 합칠 때 받는 쪽이 더 새 파일 등) @@ -3325,7 +3325,7 @@ class _ExplorerPageState extends State { // M1 (10/19): 원격 맞추기에서 받는 쪽의 같은 이름 폴더는 --delete 없이 지우지 않는다 if (job.dirConflicts.isNotEmpty) trf('같은 이름의 폴더가 있어 {0}개를 건너뛰었습니다: {1}.', [job.dirConflicts.length, _namesOf(job.dirConflicts)]), if (skippedHere.isNotEmpty) trf('이미 이 폴더에 있어 건너뛰었습니다: {0}.', [_namesOf(skippedHere)]), - ].join(' '); + ]); // 85: 끝나면 [폴더 열기] - 앱 탐색기에서 넣은 폴더로 (그 폴더를 보고 있는 창, 없으면 지금 창) if (error == null && mounted) { ScaffoldMessenger.of(context).showSnackBar(SnackBar( @@ -4646,11 +4646,11 @@ class _ExplorerPageState extends State { ..hideCurrentSnackBar() ..showSnackBar(SnackBar( duration: const Duration(seconds: 10), - content: Text([ + content: Text(joinSentences([ trf('{0}개를 옮겼습니다.', [moved]), if (notUndoable > 0) trf('합친 폴더 {0}개는 무엇을 옮겼는지 몰라 되돌리지 않습니다.', [notUndoable]), if (overwritten > 0) trf('같은 이름을 덮어쓴 파일 {0}개는 되돌리지 않습니다.', [overwritten]), - ].join(' ')), + ])), action: SnackBarAction(label: tr('되돌리기'), onPressed: () => unawaited(_undoMove(_panes[load.from], back))), )); } diff --git a/test/i18n_test.dart b/test/i18n_test.dart index 146ea9f..8b8c684 100644 --- a/test/i18n_test.dart +++ b/test/i18n_test.dart @@ -61,6 +61,18 @@ void main() { dir.deleteSync(recursive: true); }); + test('관리자 지시 (10/21): 문장 잇기 - 한국어 · 영어는 빈칸, 일본어 · 중국어는 。 뒤에 빈칸 없이', () { + for (final (lang, want) in [ + ('ko', '옮겼습니다. 남았습니다.'), + ('en', '옮겼습니다. 남았습니다.'), + ('ja', '옮겼습니다.남았습니다.'), + ('zh-Hans', '옮겼습니다.남았습니다.'), + ]) { + setTranslations(lang, const {}); + expect(joinSentences(['옮겼습니다.', '', '남았습니다.']), want, reason: lang); + } + }); + test('기본 사전: 영어 · 일본어 · 중국어가 같은 열쇠를 모두 가진다', () { final en = _asset('en'), ja = _asset('ja'), zh = _asset('zh-Hans'); expect(en.length, greaterThan(700));