끝 알림 문장 잇기: 일본어 · 중국어는 。 뒤에 빈칸 없이 (joinSentences) · 262 캡처 시험 (관리자 지시 10/21)
- l10n/tr.dart joinSentences: 한국어 · 영어는 빈칸, ja · zh 는 붙여서 (「移動しました。1 件は…」). 빈 조각은 뺌
- 파일 탐색기의 끝 알림 5곳 (복사 · 이동 끝, 긴 경로 영구 삭제, 휴지통 보내기, 휴지통 되돌리기, 끌어 놓아 옮긴 뒤) 이 이것으로 잇는다
- 시험: i18n_test (ko · en · ja · zh-Hans), moveleft 캡처 ja 960 직접 봄 - 빈칸 없음
- 262: windows_mkvend_shot_test - 가짜 FFmpeg 로 2개 묶음, 첫 동영상이 끝나는 순간 그 줄의 ■ 자리를 눌러도 ⟳ · ✕ 가 먹지 않고
둘 다 성공. ko · en · ja × 412 · 960, 정식 판 후보 1b31609 로 6개 통과 (release\test\shots_v20261020_001 mkvend_*), after_1020 으로도 6개.
i18n · explorer_conflict · batch 55개 통과
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
rc2
parent
616878d885
commit
cb9e4433d3
|
|
@ -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 = <Object?, Completer<void>>{};
|
||||
@override
|
||||
Future<String?> version() async => 'ffmpeg test';
|
||||
@override
|
||||
Future<Set<String>> encoders() async => {'libx264'};
|
||||
@override
|
||||
Future<MediaInfo> probe(String path) async => const MediaInfo(duration: Duration(seconds: 1));
|
||||
@override
|
||||
Future<void> runFfmpeg(List<String> args, {Duration? duration, ProgressCallback? onProgress, Object? tag}) async {
|
||||
final c = runs[tag] = Completer<void>();
|
||||
try {
|
||||
await c.future;
|
||||
} finally {
|
||||
runs.remove(tag);
|
||||
}
|
||||
File(args.last).writeAsStringSync('NEW');
|
||||
}
|
||||
|
||||
void _stop(Completer<void>? 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<void> 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<void>.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<void> settle([int n = 6]) async {
|
||||
for (var i = 0; i < n; i++) {
|
||||
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 50)));
|
||||
await t.pump();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> 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<void>.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));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,10 @@ Map<String, String> _fallback = const {};
|
|||
/// 지금 화면 언어 코드 (ko · en · ja · zh-Hans …)
|
||||
String get uiLanguage => _lang;
|
||||
|
||||
/// 문장 여럿을 한 줄로 잇는다 - 한국어 · 영어는 빈칸, 일본어 · 중국어는 붙여서 (。 뒤에 빈칸을 두지 않음, 관리자 지시 10/21)
|
||||
String joinSentences(Iterable<String> parts) =>
|
||||
parts.where((x) => x.isNotEmpty).join(_lang.startsWith('ja') || _lang.startsWith('zh') ? '' : ' ');
|
||||
|
||||
/// 화면 언어와 사전을 바꾼다. [fallback] 은 사전에 없는 글자를 찾을 사전 (보통 영어).
|
||||
void setTranslations(String lang, Map<String, String> dict, [Map<String, String> fallback = const {}]) {
|
||||
_lang = lang;
|
||||
|
|
|
|||
|
|
@ -2881,18 +2881,18 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
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<ExplorerPage> {
|
|||
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<ExplorerPage> {
|
|||
? 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<ExplorerPage> {
|
|||
// 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<ExplorerPage> {
|
|||
..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))),
|
||||
));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
Loading…
Reference in New Issue