batch_test 나누기 (971 → 361 + 536): 화면 시험은 batch_ui_test, 대역은 support/batch_doubles

- 시험 수 그대로 (나누기 전 29 = 나눈 뒤 29), 기대값 그대로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
modular
RyuJongJin 2026-10-23 03:26:56 +09:00
parent 49745c0d52
commit db38d0ddbf
3 changed files with 644 additions and 616 deletions

View File

@ -1,120 +1,18 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/app/app_controller.dart';
import 'package:jj_mkvmaker/app/bookmarks_controller.dart';
import 'package:jj_mkvmaker/app/download_manager.dart';
import 'package:jj_mkvmaker/app/settings.dart';
import 'package:jj_mkvmaker/core/download_detect.dart';
import 'package:jj_mkvmaker/core/encode_options.dart';
import 'package:jj_mkvmaker/core/models.dart';
import 'package:jj_mkvmaker/core/output_paths.dart';
import 'package:jj_mkvmaker/core/srt.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/ai_services.dart';
import 'package:jj_mkvmaker/services/downloader.dart';
import 'package:jj_mkvmaker/services/media_tool.dart';
import 'package:jj_mkvmaker/services/model_store.dart';
import 'package:jj_mkvmaker/services/platform_services.dart';
import 'package:jj_mkvmaker/ui/app_actions.dart';
import 'package:jj_mkvmaker/ui/browser_page.dart';
import 'package:jj_mkvmaker/ui/downloads_page.dart';
import 'package:jj_mkvmaker/ui/home_page.dart';
import 'package:jj_mkvmaker/ui/settings_page.dart';
import 'package:path/path.dart' as p;
import 'support/temp_dirs.dart';
class _Recognizer implements SpeechRecognizer {
@override
void cancel() {}
final heard = <String>[];
@override
Future<List<Cue>> transcribe(String wavPath,
{required String modelPath, String language = 'auto', AiProgress? onProgress}) async {
heard.add(p.basename(wavPath));
return [Cue(const Duration(seconds: 1), const Duration(seconds: 2), 'Hello everyone, this is a test.')];
}
}
/// 240: 취소될 때까지 끝나지 않는 FFmpeg (여럿이 동시에 도는 상태를 만든다)
class _HoldTool 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);
}
}
void _stop(Completer<void>? c) {
if (c != null && !c.isCompleted) c.completeError(MediaToolException('사용자가 취소했습니다.'));
}
@override
void cancel() => runs.values.toList().forEach(_stop);
@override
void cancelTagged(Object tag) => _stop(runs[tag]);
}
class _Translator implements Translator {
@override
Future<void> load(String modelDir) async {}
@override
Future<List<String>> translate(List<String> lines,
{required String source, required String target, AiProgress? onProgress}) async =>
[for (final l in lines) '[$target] $l'];
@override
void cancel() {}
@override
Future<void> dispose() async {}
}
class _Backend implements DownloadBackend {
@override
DownloadKind get kind => DownloadKind.video;
@override
Future<(String, List<PlaylistEntry>)?> expandPlaylist(String url) async => null;
@override
Future<void> start(DownloadTask t, void Function() changed) async => t.state = DownloadState.downloading;
@override
Future<void> pause(DownloadTask t) async {}
@override
Future<void> cancel(DownloadTask t) async {}
@override
Future<void> shutdown() async {}
}
/// 262: [_HoldTool] 과 같지만, 끝내면 (결과 이름으로 바꿀) 임시 출력 파일을 쓴다
class _EndTool extends _HoldTool {
final args = <Object?, List<String>>{};
@override
Future<void> runFfmpeg(List<String> a, {Duration? duration, ProgressCallback? onProgress, Object? tag}) async {
args[tag] = a;
await super.runFfmpeg(a, duration: duration, onProgress: onProgress, tag: tag);
File(a.last).writeAsStringSync('NEW');
}
/// [tag] 의 FFmpeg 를 성공으로 끝낸다
void finish(Object tag) => runs[tag]!.complete();
}
AppController _plain() =>
AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
import 'support/batch_doubles.dart';
void main() {
deleteTempDirsAtEnd();
@ -125,7 +23,7 @@ void main() {
for (final f in files) {
File(f).writeAsStringSync('x');
}
final c = _plain();
final c = plainController();
await c.addVideos(files);
final [a, b, cc] = c.videos;
@ -162,7 +60,7 @@ void main() {
..writeAsStringSync('x')
..setLastModifiedSync(DateTime(2026, 1, e.value));
}
final c = _plain();
final c = plainController();
await c.addVideos([for (final n in when.keys) p.join(dir.path, n)]);
List<String> names() => [for (final v in c.videos) v.fileName];
expect(names(), ['ep10.mp4', 'intro.mp4', 'ep2.mp4']);
@ -208,12 +106,12 @@ void main() {
..writeAsStringSync('x');
}
}
final rec = _Recognizer();
final rec = TestRecognizer();
final c = AppController(PlatformServices(
mediaTool: tool,
storage: DesktopStorageService(),
createRecognizer: () => rec,
createTranslator: _Translator.new,
createTranslator: TestTranslator.new,
models: models,
));
await c.init();
@ -415,335 +313,8 @@ void main() {
reason: '${v1.message} ${v2.message} ${v3.message}');
}, timeout: const Timeout(Duration(minutes: 4)));
testWidgets('198: 원어를 자동 감지한 동영상은 세부 정보에 감지한 언어 · [다른 언어로 다시] (알림을 놓쳐도)', (tester) async {
tester.view.physicalSize = const Size(1600, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.videos.add(VideoItem(r'D:\v\a.mp4')..aiDetected = 'ko');
c.selected = c.videos.first;
await tester.pumpWidget(MaterialApp(home: HomePage(c: c)));
expect(find.text('감지한 언어: 한국어 (자동 감지)'), findsOneWidget);
expect(find.widgetWithText(TextButton, '다른 언어로 다시'), findsOneWidget);
// 원어를 직접 고르면 (null) 보이지 않는다
c.videos.first.aiDetected = null;
c.selected = c.videos.first;
await tester.pumpWidget(MaterialApp(key: UniqueKey(), home: HomePage(c: c)));
expect(find.textContaining('감지한 언어'), findsNothing);
});
for (final (name, size, targets) in [
('폰 세로', const Size(412, 900), 0),
('폰 세로 · 이동 버튼 3개', const Size(412, 900), 3),
('넓은 화면 · 이동 버튼 3개', const Size(1600, 520), 3),
]) {
testWidgets('206: MKV 자세히 칸의 [이동] 버튼이 칩 (음성 n개) 을 가리지 않는다 ($name)', (tester) async {
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings.mkv.moveTargets = [for (var i = 0; i < targets; i++) MoveTarget('보관$i', 'D:\\m$i')];
c.videos.add(VideoItem(r'D:\v\a.mp4')
..info = const MediaInfo(duration: Duration(minutes: 3), streams: [
StreamInfo(index: 0, type: 'video', codec: 'h264', width: 1920, height: 1080),
StreamInfo(index: 1, type: 'audio', codec: 'aac'),
]));
c.selected = c.videos.first;
await tester.pumpWidget(MaterialApp(home: HomePage(c: c)));
await tester.pump();
final chip = find.text('음성 1개');
// 끝까지 내려 본다 (넓은 화면은 떠 있는 버튼 - 끝에서도 가리지 않아야)
await tester.dragUntilVisible(chip, find.byType(ListView).last, const Offset(0, -80));
await tester.drag(find.byType(ListView).last, const Offset(0, -2000));
await tester.pumpAndSettle();
expect(chip, findsOneWidget);
final r = tester.getRect(chip);
for (final b in find.byType(FloatingActionButton).evaluate()) {
final br = tester.getRect(find.byWidget(b.widget));
expect(br.overlaps(r), isFalse, reason: '버튼 $br · 칩 $r');
}
});
}
testWidgets('MKV 화면: 체크 → "자막 만들기 (2)" · "MKV 만들기 (2)" 글, 전체 선택', (tester) async {
tester.view.physicalSize = const Size(1600, 900); // 위쪽 버튼 글이 다 보이는 너비
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final c = AppController(PlatformServices(
mediaTool: ProcessMediaTool('x', 'y'),
storage: DesktopStorageService(),
createRecognizer: _Recognizer.new,
createTranslator: _Translator.new,
));
for (final n in ['a', 'b', 'c']) {
c.videos.add(VideoItem('D:\\v\\$n.mp4'));
}
c.selected = c.videos.first;
await tester.pumpWidget(MaterialApp(home: HomePage(c: c)));
expect(find.text('자막 만들기'), findsOneWidget);
expect(find.text('자막 만들기 & MKV 만들기'), findsOneWidget);
expect(find.text('동영상 3개 · 전체 선택'), findsOneWidget);
// 줄의 체크 상자 (0 번째는 전체 선택)
// (줄에 더블클릭 = 재생이 있어 탭은 더블클릭 대기 시간 뒤에 확정된다)
await tester.tap(find.byType(Checkbox).at(1));
await tester.pump(const Duration(milliseconds: 400));
await tester.tap(find.byType(Checkbox).at(3));
await tester.pump(const Duration(milliseconds: 400));
expect(c.checked, {c.videos[0], c.videos[2]});
expect(find.text('선택 2 / 3개'), findsOneWidget);
expect(find.text('자막 만들기 (2)'), findsOneWidget);
expect(find.text('자막 만들기 & MKV 만들기 (2)'), findsOneWidget);
expect(find.text('MKV 만들기 (2)'), findsOneWidget);
expect(find.byTooltip(RegExp('^파일 이름 순으로 정렬')), findsOneWidget);
expect(find.byTooltip(RegExp('^날짜 순으로 정렬')), findsOneWidget);
await tester.tap(find.text('선택 2 / 3개'));
await tester.pump();
expect(c.checked, hasLength(3));
await tester.tap(find.byTooltip('선택한 동영상을 목록에서 제거'));
await tester.pump();
expect(c.videos, isEmpty);
});
testWidgets('다운로드 화면: 다 받은 것을 골라 [동영상 추가] → MKV 목록', (tester) async {
tester.view.physicalSize = const Size(1500, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final dir = Directory.systemTemp.createTempSync('jj_dladd_');
addTearDown(() => dir.deleteSync(recursive: true));
final file = p.join(dir.path, 'movie.mp4');
File(file).writeAsStringSync('x');
final d = DownloadManager(
backends: [_Backend()], settings: () => AppSettings()..download.root = dir.path, readClipboard: () async => null);
final added = <String>[];
d.addToEditList = (files) async {
added.addAll(files);
return files.length;
};
final done = d.addPage('https://vimeo.com/1')!
..title = '다 받은 영상'
..files.add(file)
..state = DownloadState.done;
d.addPage('https://vimeo.com/2')!.title = '받는 중인 영상';
await tester.pumpWidget(MaterialApp(home: DownloadsPage(d: d)));
OutlinedButton btn() => tester.widget<OutlinedButton>(
find.ancestor(of: find.text('동영상 추가'), matching: find.byWidgetPredicate((w) => w is OutlinedButton)));
expect(btn().onPressed, isNull); // 고른 것이 없음
await tester.tap(find.text('받는 중인 영상'));
await tester.pump();
expect(btn().onPressed, isNull); // 아직 다 받지 않음
await tester.tap(find.text('다 받은 영상'));
await tester.pump();
expect(d.selectedVideoFiles, [file]);
await tester.tap(find.text('동영상 추가'));
await tester.pump();
expect(added, [file]);
expect(find.textContaining('동영상 1개를 추가했습니다'), findsOneWidget);
expect(done.state, DownloadState.done);
d.dispose();
});
testWidgets('다운로드 화면: "완료 시 자동 동영상 추가" 체크 → 설정 변경, 목록에서 빼기', (tester) async {
tester.view.physicalSize = const Size(1500, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final s = AppSettings()..download.addFinished = false;
final d = DownloadManager(backends: [_Backend()], settings: () => s, readClipboard: () async => null);
d.addToEditList = (files) async => files.length;
d.setAutoAdd = (on) async {
s.download.addFinished = on;
d.refresh();
};
final t = d.addPage('https://vimeo.com/1')!..title = '영상';
await tester.pumpWidget(MaterialApp(home: DownloadsPage(d: d)));
Checkbox box() => tester.widget<Checkbox>(
find.descendant(of: find.byTooltip(RegExp('MKV 만들기의 동영상 목록에 자동으로')), matching: find.byType(Checkbox)));
expect(box().value, isFalse);
await tester.tap(find.text('완료 시 자동 동영상 추가'));
await tester.pump();
expect(s.download.addFinished, isTrue);
expect(box().value, isTrue);
// 다 받으면 목록에서 뺀다 (앱이 onFinished 에서 부름)
d.dropFromList(t);
await tester.pump();
expect(d.tasks, isEmpty);
expect(find.text('영상'), findsNothing);
d.dispose();
});
// 246 · P11 확인 (921): 영어 · 일본어는 글이 길어 체크가 아예 안 보였다 - 언어 · 폭마다
for (final lang in ['ko', 'en', 'ja']) {
for (final w in [800.0, 1200.0]) {
testWidgets('246: 다운로드 화면 ($lang, 폭 $w) - 버튼 줄의 글이 잘리거나 "전체 선택" 과 겹치지 않는다', (tester) async {
Map<String, String> dict(String code) =>
Map<String, String>.from(jsonDecode(File('assets/l10n/$code.json').readAsStringSync()) as Map);
setTranslations(lang, lang == 'ko' ? const {} : dict(lang), lang == 'ko' ? const {} : dict('en'));
addTearDown(() => setTranslations('ko', const {}));
tester.view.physicalSize = Size(w, 411);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final s = AppSettings();
final d = DownloadManager(backends: [_Backend()], settings: () => s, readClipboard: () async => null);
addTearDown(d.dispose);
d.addToEditList = (files) async => files.length;
d.setAutoAdd = (on) async => s.download.addFinished = on;
d.addPage('https://vimeo.com/1')!.title = '영상';
await tester.pumpWidget(MaterialApp(home: DownloadsPage(d: d)));
await tester.pump();
expect(tester.takeException(), isNull);
final all = tester.getRect(find.text(tr('전체 선택')));
final auto = tester.getRect(find.text(tr('완료 시 자동 동영상 추가')));
expect(auto.overlaps(all), isFalse, reason: '$auto · $all');
expect([auto.left >= 0, auto.right <= w], [true, true], reason: '화면 안에 다 보임 $auto');
for (final t in ['일시정지', '재개', '다운로드 취소', '삭제', '완료 정리']) {
final r = tester.getRect(find.text(tr(t)).first);
expect([r.left >= 0, r.right <= w], [true, true], reason: '$t $r');
}
});
}
}
testWidgets('환경 설정 · 종료 버튼: 모든 화면에서 같은 자리', (tester) async {
tester.view.physicalSize = const Size(1500, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final dir = Directory.systemTemp.createTempSync('jj_pos_');
addTearDown(() => dir.deleteSync(recursive: true));
final c = _plain();
final d = DownloadManager(backends: [_Backend()], settings: () => c.settings, readClipboard: () async => null);
final bm = BookmarksController(p.join(dir.path, 'bm.json'));
var exits = 0;
Future<(Offset, Offset, Offset, Offset)> at(Widget page) async {
await tester.pumpWidget(MaterialApp(
key: UniqueKey(),
builder: (context, child) =>
AppScope(controller: c, onExit: () => exits++, downloads: d, bookmarks: bm, child: child!),
home: page,
));
await tester.pump();
return (
tester.getCenter(find.byTooltip('환경 설정')),
tester.getCenter(find.byTooltip('종료')),
// 왼쪽 공통 버튼: JJ (홈) · 다운로드 목록
tester.getCenter(find.byTooltip(RegExp('^홈 화면'))),
tester.getCenter(find.byTooltip(RegExp('^다운로드 목록'))),
);
}
final home = await at(HomePage(c: c, downloads: d, bookmarks: bm, onExit: () => exits++));
final pages = {
'다운로드': await at(DownloadsPage(d: d)),
'환경 설정': await at(SettingsPage(c: c)),
'브라우저': await at(BrowserPage(
c: c, bookmarks: bm, downloads: d, viewBuilder: (h, url) => const ColoredBox(color: Colors.white))),
};
for (final e in pages.entries) {
expect(e.value, home, reason: '${e.key} 화면의 버튼 자리가 MKV 화면과 다릅니다');
}
// 오른쪽 위 구석
expect(home.$2.dx, greaterThan(1500 - 60));
expect(home.$2.dy, 28);
// 왼쪽 위 구석: JJ 아이콘 (홈)
expect(home.$3.dx, lessThan(40));
expect(home.$3.dy, 28);
// 브라우저에서 종료 버튼 → 앱의 종료 동작
await tester.tap(find.byTooltip('종료'));
expect(exits, 1);
d.dispose();
});
testWidgets('MKV 만들기: 체크가 없으면 보고 있는 동영상만, 보고 있는 것도 없으면 "전체 만들기 / 취소" 를 묻는다', (tester) async {
tester.view.physicalSize = const Size(1500, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final c = _plain()..ffmpegVersion = 'test';
c.videos.addAll([VideoItem(r'D:\v\a.mp4'), VideoItem(r'D:\v\b.mp4')]);
c.selected = null;
await tester.pumpWidget(MaterialApp(home: HomePage(c: c)));
await tester.tap(find.text('MKV 만들기'));
await tester.pumpAndSettle();
expect(find.textContaining('선택한 동영상이 없습니다'), findsOneWidget);
expect(find.textContaining('목록 전체 2개'), findsOneWidget);
await tester.tap(find.text('취소'));
await tester.pumpAndSettle();
expect(c.busy, isFalse); // 취소하면 아무것도 하지 않음
// 하나라도 고르면 묻지 않는다 (버튼 글에 개수)
c.toggleChecked(c.videos.last);
await tester.pump();
expect(find.text('MKV 만들기 (1)'), findsOneWidget);
});
testWidgets('왼쪽 공통 버튼: JJ (홈) · MKV 화면으로 · 뒤로 · 다운로드 목록', (tester) async {
tester.view.physicalSize = const Size(1500, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final dir = tempDirUntilEnd('jj_nav_');
final c = _plain();
final d = DownloadManager(backends: [_Backend()], settings: () => c.settings, readClipboard: () async => null);
final bm = BookmarksController(p.join(dir.path, 'bm.json'));
await tester.pumpWidget(MaterialApp(
builder: (context, child) => AppScope(controller: c, downloads: d, bookmarks: bm, child: child!),
home: HomePage(c: c, downloads: d, bookmarks: bm),
));
IconButton btn(String tip) =>
tester.widget<IconButton>(find.ancestor(of: find.byTooltip(RegExp('^$tip')), matching: find.byType(IconButton)).first);
// MKV 화면: 뒤로 · MKV 는 "지금 여기" 라 꺼짐, 다운로드 목록 열기
expect(btn('뒤로').onPressed, isNull);
expect(find.byTooltip('MKV 화면 (지금 여기)'), findsOneWidget);
// 지금 화면 버튼 아래 밑줄 하나 (그 버튼 아래에)
final line = find.byKey(const ValueKey('nav-here-underline'));
expect(line, findsOneWidget);
final mkv = tester.getRect(find.byTooltip('MKV 화면 (지금 여기)'));
expect(tester.getCenter(line).dx, closeTo(mkv.center.dx, 2));
await tester.tap(find.byTooltip('다운로드 목록'));
await tester.pumpAndSettle();
expect(find.textContaining('다운로드 ('), findsOneWidget);
expect(find.byTooltip('다운로드 목록 (지금 여기)'), findsOneWidget);
// 다운로드 → 환경 설정 → MKV 화면으로 (한 번에 처음까지)
await tester.tap(find.byTooltip('환경 설정').first);
await tester.pumpAndSettle();
expect(find.text('환경 설정'), findsWidgets);
await tester.tap(find.byTooltip('MKV 화면으로'));
await tester.pumpAndSettle();
expect(find.byTooltip('MKV 화면 (지금 여기)'), findsOneWidget);
// 뒤로: 한 단계만
await tester.tap(find.byTooltip('다운로드 목록'));
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('뒤로'));
await tester.pumpAndSettle();
expect(find.byTooltip('MKV 화면 (지금 여기)'), findsOneWidget);
// 홈 화면을 웹 브라우저로 정하면 JJ 아이콘이 브라우저를 연다 (MKV 버튼은 그대로 있음)
c.settings.display.startScreen = 'browser';
await tester.tap(find.byTooltip('다운로드 목록'));
await tester.pumpAndSettle();
await tester.tap(find.byTooltip(RegExp('^홈 화면 \\(웹 브라우저\\)')));
// 화면 전환 (닫히는 화면 · 새 화면) 이 끝날 때까지. 브라우저는 계속 움직여서 pumpAndSettle 은 쓰지 않는다.
for (var i = 0; i < 6; i++) {
await tester.pump(const Duration(milliseconds: 200));
}
expect(find.byType(BrowserPage), findsOneWidget);
expect(find.byTooltip('MKV 화면으로'), findsOneWidget);
await tester.pumpWidget(const SizedBox());
d.dispose();
});
test('262: 동영상이 끝나는 바로 그때 줄 ■ (지금 것만 취소) 가 와도 성공 그대로 - 줄 · 집계 · 파일', () async {
final tool = _EndTool();
final tool = EndTool();
final dir = Directory.systemTemp.createTempSync('jj_batch262_');
addTearDown(() => dir.deleteSync(recursive: true));
final files = [for (final n in ['batch1', 'batch2', 'batch3']) (File(p.join(dir.path, '$n.mp4'))..writeAsBytesSync([0])).path];
@ -787,185 +358,4 @@ void main() {
c.resetStatus(v1);
expect(v1.status, JobStatus.ready);
});
testWidgets('262: 끝나는 순간 줄의 ■ 자리에 오는 ⟳ 는 지금 묶음이 끝날 때까지 눌리지 않는다 (✕ 도 그 자리로 밀려오지 않음)', (tester) async {
tester.view.physicalSize = const Size(1600, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final tool = _EndTool();
final dir = Directory.systemTemp.createTempSync('jj_batch262w_');
addTearDown(() => dir.deleteSync(recursive: true));
final files = [for (final n in ['a', 'b']) (File(p.join(dir.path, '$n.mp4'))..writeAsBytesSync([0])).path];
final c = AppController(PlatformServices(mediaTool: tool, storage: DesktopStorageService()));
late Future<void> building;
await tester.runAsync(() async {
await c.init();
c.settings.mkv.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;
await tester.pumpWidget(MaterialApp(home: HomePage(c: c)));
await tester.pump();
final stopAt = tester.getCenter(find.byTooltip('이 동영상만 취소'));
// a 가 끝나고 b 가 시작
await tester.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 tester.pump();
expect(a.status, JobStatus.done);
// ■ 를 누르려던 자리를 누른다
await tester.tapAt(stopAt);
await tester.pump();
expect(a.status, JobStatus.done, reason: '⟳ 로 대기가 되지 않음');
expect(c.videos, contains(a), reason: '✕ 로 빠지지 않음');
await tester.runAsync(() async {
tool.finish(b);
await building;
});
await tester.pump();
expect(c.logs.join('\n'), contains('성공 2 · 취소 0 · 실패 0 / 전체 2'));
// 누른 자리의 풍선 도움말 타이머 정리
await tester.pumpWidget(const SizedBox());
await tester.pump(const Duration(seconds: 2));
});
testWidgets('240: 여럿이 동시에 돌 때 ■ 는 묻는다 ([그대로 두기] · [모두 취소 (n개)]) · 목록 줄의 ■ 는 그 동영상만', (tester) async {
tester.view.physicalSize = const Size(1600, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final tool = _HoldTool();
final dir = Directory.systemTemp.createTempSync('jj_batch240_');
addTearDown(() => dir.deleteSync(recursive: true));
final files = [
for (final n in ['a', 'b']) (File(p.join(dir.path, '$n.mp4'))..writeAsBytesSync([0])).path,
];
final c = AppController(PlatformServices(mediaTool: tool, storage: DesktopStorageService()));
late Future<void> building;
await tester.runAsync(() async {
await c.init();
c.settings.mkv.maxParallelJobs = 5;
await c.addVideos(files);
building = c.buildVideos([...c.videos]);
for (var i = 0; i < 200 && tool.runs.length < 2; i++) {
await Future<void>.delayed(const Duration(milliseconds: 10));
}
});
final [a, b] = c.videos;
expect([a.status, b.status], [JobStatus.running, JobStatus.running]);
await tester.pumpWidget(MaterialApp(home: HomePage(c: c)));
await tester.pump();
// ■ (위 막대): 둘이 돌고 남은 것은 없음 → 묻는다
await tester.tap(find.byTooltip('작업 취소').first);
await tester.pump(const Duration(milliseconds: 400)); // 도는 동안은 진행 막대가 움직여 pumpAndSettle 이 끝나지 않는다
expect(find.text('모두 취소 (2개)'), findsOneWidget);
await tester.tap(find.text('그대로 두기'));
await tester.pump(const Duration(milliseconds: 400)); // 도는 동안은 진행 막대가 움직여 pumpAndSettle 이 끝나지 않는다
expect(tool.runs.length, 2, reason: '[그대로 두기] 는 아무것도 취소하지 않음');
// 목록 줄의 ■: 그 동영상만
expect(find.byTooltip('이 동영상만 취소'), findsNWidgets(2));
await tester.tap(find.byTooltip('이 동영상만 취소').first);
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 100)));
await tester.pump();
expect([a.status, b.status], [JobStatus.cancelled, JobStatus.running]);
// 하나만 남으면 ■ 는 묻지 않고 바로 취소
await tester.tap(find.byTooltip('작업 취소').first);
await tester.runAsync(() => building);
await tester.pump();
expect(b.status, JobStatus.cancelled);
expect(find.text('모두 취소 (2개)'), findsNothing);
});
testWidgets('242: 폰 가로 (노트 10+ - 내비게이션 막대를 뺀 약 800×411) 에서 작업 중이어도 위 막대 버튼이 화면 크기 "100%" 를 덮지 않는다', (tester) async {
final w = double.parse(Platform.environment['JJ_W'] ?? '800');
tester.view.physicalSize = Size(w, 411);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final tool = _HoldTool();
final dir = Directory.systemTemp.createTempSync('jj_batch242_');
addTearDown(() => dir.deleteSync(recursive: true));
final file = (File(p.join(dir.path, 'a.mp4'))..writeAsBytesSync([0])).path;
final c = AppController(PlatformServices(mediaTool: tool, storage: DesktopStorageService()));
late Future<void> building;
await tester.runAsync(() async {
await c.init();
await c.addVideos([file]);
c.selected = c.videos.first;
building = c.buildVideos([...c.videos]);
for (var i = 0; i < 200 && tool.runs.isEmpty; i++) {
await Future<void>.delayed(const Duration(milliseconds: 10));
}
});
expect(c.busy, isTrue);
await tester.pumpWidget(MaterialApp(home: HomePage(c: c)));
// 첫 그림에서 재어 보고 모자라면 다음 그림부터 두 줄 (첫 그림도 넘침 오류 없이 잘라 그림)
expect(tester.takeException(), isNull);
await tester.pump();
await tester.pump();
expect(tester.takeException(), isNull, reason: '두 줄로 바뀐 뒤에는 넘침 없음');
final pct = tester.getRect(find.text('${(c.settings.display.scale * 100).round()}%'));
for (final tip in ['작업 취소', '결과 폴더 열기: ${outputDirFor(file)}']) {
final f = find.byTooltip(tip);
expect(f, findsWidgets, reason: tip);
final r = tester.getRect(f.first);
expect(r.overlaps(pct), isFalse, reason: '$tip 버튼 $r 이 "100%" $pct 를 덮음');
}
c.cancel();
await tester.runAsync(() => building);
await tester.pump();
});
testWidgets('폰 세로 (노트 10+ 약 411×800): 여러 개를 만드는 동안 위 막대 진행은 "n/3 · 37%" 짧은 꼴 · [+ 동영상 추가] 를 가리지 않는다', (tester) async {
tester.view.physicalSize = const Size(411, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final tool = _HoldTool();
final dir = Directory.systemTemp.createTempSync('jj_batch_portrait_');
addTearDown(() => dir.deleteSync(recursive: true));
final files = [for (final n in ['a', 'b', 'c']) (File(p.join(dir.path, '$n.mp4'))..writeAsBytesSync([0])).path];
final c = AppController(PlatformServices(mediaTool: tool, storage: DesktopStorageService()));
late Future<void> building;
await tester.runAsync(() async {
await c.init();
await c.addVideos(files);
c.selected = c.videos.first;
building = c.buildVideos([...c.videos]);
for (var i = 0; i < 200 && tool.runs.isEmpty; i++) {
await Future<void>.delayed(const Duration(milliseconds: 10));
}
});
expect(c.busy, isTrue);
expect(c.batchTotal, 3);
await tester.pumpWidget(MaterialApp(home: HomePage(c: c)));
await tester.pump();
await tester.pump();
// 위 막대를 잰 뒤에 진행률이 생긴다 (예전에는 이때 글이 넓어져 [+] 를 가렸다)
for (final v in c.videos.where((v) => v.status == JobStatus.running)) {
v.progress = 0.37;
}
await c.updateSettings((_) {});
await tester.pump();
expect(tester.takeException(), isNull);
final prog = find.textContaining(RegExp(r'^\d+/3 · 37%$'));
expect(prog, findsOneWidget);
final add = find.byTooltip(Platform.isAndroid ? '동영상 추가' : '동영상 추가 (탐색기에서 끌어다 놓아도 됩니다)');
expect(add, findsWidgets);
final r = tester.getRect(prog);
// [+] 는 옆으로 미는 줄 안에 있어 끝이 잘려 보일 수 있다 - 실제로 보이는 부분 (밀기 줄 안쪽) 이 진행 글과 겹치지 않아야 한다
for (final e in add.evaluate()) {
final a = tester.getRect(find.byWidget(e.widget).first);
final view = tester.getRect(find.ancestor(of: find.byWidget(e.widget).first, matching: find.byType(Scrollable)).first);
final seen = a.intersect(view);
expect(seen.width > 0 && seen.overlaps(r), isFalse, reason: '[+] 의 보이는 부분 $seen 이 진행 글 $r 와 겹침');
}
c.cancel();
await tester.runAsync(() => building);
await tester.pump();
});
}

536
test/batch_ui_test.dart Normal file
View File

@ -0,0 +1,536 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/app/app_controller.dart';
import 'package:jj_mkvmaker/app/bookmarks_controller.dart';
import 'package:jj_mkvmaker/app/download_manager.dart';
import 'package:jj_mkvmaker/app/settings.dart';
import 'package:jj_mkvmaker/core/models.dart';
import 'package:jj_mkvmaker/core/output_paths.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/downloader.dart';
import 'package:jj_mkvmaker/services/platform_services.dart';
import 'package:jj_mkvmaker/ui/app_actions.dart';
import 'package:jj_mkvmaker/ui/browser_page.dart';
import 'package:jj_mkvmaker/ui/downloads_page.dart';
import 'package:jj_mkvmaker/ui/home_page.dart';
import 'package:jj_mkvmaker/ui/settings_page.dart';
import 'package:path/path.dart' as p;
import 'support/temp_dirs.dart';
import 'support/batch_doubles.dart';
void main() {
deleteTempDirsAtEnd();
testWidgets('198: 원어를 자동 감지한 동영상은 세부 정보에 감지한 언어 · [다른 언어로 다시] (알림을 놓쳐도)', (tester) async {
tester.view.physicalSize = const Size(1600, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.videos.add(VideoItem(r'D:\v\a.mp4')..aiDetected = 'ko');
c.selected = c.videos.first;
await tester.pumpWidget(MaterialApp(home: HomePage(c: c)));
expect(find.text('감지한 언어: 한국어 (자동 감지)'), findsOneWidget);
expect(find.widgetWithText(TextButton, '다른 언어로 다시'), findsOneWidget);
// 원어를 직접 고르면 (null) 보이지 않는다
c.videos.first.aiDetected = null;
c.selected = c.videos.first;
await tester.pumpWidget(MaterialApp(key: UniqueKey(), home: HomePage(c: c)));
expect(find.textContaining('감지한 언어'), findsNothing);
});
for (final (name, size, targets) in [
('폰 세로', const Size(412, 900), 0),
('폰 세로 · 이동 버튼 3개', const Size(412, 900), 3),
('넓은 화면 · 이동 버튼 3개', const Size(1600, 520), 3),
]) {
testWidgets('206: MKV 자세히 칸의 [이동] 버튼이 칩 (음성 n개) 을 가리지 않는다 ($name)', (tester) async {
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings.mkv.moveTargets = [for (var i = 0; i < targets; i++) MoveTarget('보관$i', 'D:\\m$i')];
c.videos.add(VideoItem(r'D:\v\a.mp4')
..info = const MediaInfo(duration: Duration(minutes: 3), streams: [
StreamInfo(index: 0, type: 'video', codec: 'h264', width: 1920, height: 1080),
StreamInfo(index: 1, type: 'audio', codec: 'aac'),
]));
c.selected = c.videos.first;
await tester.pumpWidget(MaterialApp(home: HomePage(c: c)));
await tester.pump();
final chip = find.text('음성 1개');
// 끝까지 내려 본다 (넓은 화면은 떠 있는 버튼 - 끝에서도 가리지 않아야)
await tester.dragUntilVisible(chip, find.byType(ListView).last, const Offset(0, -80));
await tester.drag(find.byType(ListView).last, const Offset(0, -2000));
await tester.pumpAndSettle();
expect(chip, findsOneWidget);
final r = tester.getRect(chip);
for (final b in find.byType(FloatingActionButton).evaluate()) {
final br = tester.getRect(find.byWidget(b.widget));
expect(br.overlaps(r), isFalse, reason: '버튼 $br · 칩 $r');
}
});
}
testWidgets('MKV 화면: 체크 → "자막 만들기 (2)" · "MKV 만들기 (2)" 글, 전체 선택', (tester) async {
tester.view.physicalSize = const Size(1600, 900); // 위쪽 버튼 글이 다 보이는 너비
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final c = AppController(PlatformServices(
mediaTool: ProcessMediaTool('x', 'y'),
storage: DesktopStorageService(),
createRecognizer: TestRecognizer.new,
createTranslator: TestTranslator.new,
));
for (final n in ['a', 'b', 'c']) {
c.videos.add(VideoItem('D:\\v\\$n.mp4'));
}
c.selected = c.videos.first;
await tester.pumpWidget(MaterialApp(home: HomePage(c: c)));
expect(find.text('자막 만들기'), findsOneWidget);
expect(find.text('자막 만들기 & MKV 만들기'), findsOneWidget);
expect(find.text('동영상 3개 · 전체 선택'), findsOneWidget);
// 줄의 체크 상자 (0 번째는 전체 선택)
// (줄에 더블클릭 = 재생이 있어 탭은 더블클릭 대기 시간 뒤에 확정된다)
await tester.tap(find.byType(Checkbox).at(1));
await tester.pump(const Duration(milliseconds: 400));
await tester.tap(find.byType(Checkbox).at(3));
await tester.pump(const Duration(milliseconds: 400));
expect(c.checked, {c.videos[0], c.videos[2]});
expect(find.text('선택 2 / 3개'), findsOneWidget);
expect(find.text('자막 만들기 (2)'), findsOneWidget);
expect(find.text('자막 만들기 & MKV 만들기 (2)'), findsOneWidget);
expect(find.text('MKV 만들기 (2)'), findsOneWidget);
expect(find.byTooltip(RegExp('^파일 이름 순으로 정렬')), findsOneWidget);
expect(find.byTooltip(RegExp('^날짜 순으로 정렬')), findsOneWidget);
await tester.tap(find.text('선택 2 / 3개'));
await tester.pump();
expect(c.checked, hasLength(3));
await tester.tap(find.byTooltip('선택한 동영상을 목록에서 제거'));
await tester.pump();
expect(c.videos, isEmpty);
});
testWidgets('다운로드 화면: 다 받은 것을 골라 [동영상 추가] → MKV 목록', (tester) async {
tester.view.physicalSize = const Size(1500, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final dir = Directory.systemTemp.createTempSync('jj_dladd_');
addTearDown(() => dir.deleteSync(recursive: true));
final file = p.join(dir.path, 'movie.mp4');
File(file).writeAsStringSync('x');
final d = DownloadManager(
backends: [TestDownloadBackend()], settings: () => AppSettings()..download.root = dir.path, readClipboard: () async => null);
final added = <String>[];
d.addToEditList = (files) async {
added.addAll(files);
return files.length;
};
final done = d.addPage('https://vimeo.com/1')!
..title = '다 받은 영상'
..files.add(file)
..state = DownloadState.done;
d.addPage('https://vimeo.com/2')!.title = '받는 중인 영상';
await tester.pumpWidget(MaterialApp(home: DownloadsPage(d: d)));
OutlinedButton btn() => tester.widget<OutlinedButton>(
find.ancestor(of: find.text('동영상 추가'), matching: find.byWidgetPredicate((w) => w is OutlinedButton)));
expect(btn().onPressed, isNull); // 고른 것이 없음
await tester.tap(find.text('받는 중인 영상'));
await tester.pump();
expect(btn().onPressed, isNull); // 아직 다 받지 않음
await tester.tap(find.text('다 받은 영상'));
await tester.pump();
expect(d.selectedVideoFiles, [file]);
await tester.tap(find.text('동영상 추가'));
await tester.pump();
expect(added, [file]);
expect(find.textContaining('동영상 1개를 추가했습니다'), findsOneWidget);
expect(done.state, DownloadState.done);
d.dispose();
});
testWidgets('다운로드 화면: "완료 시 자동 동영상 추가" 체크 → 설정 변경, 목록에서 빼기', (tester) async {
tester.view.physicalSize = const Size(1500, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final s = AppSettings()..download.addFinished = false;
final d = DownloadManager(backends: [TestDownloadBackend()], settings: () => s, readClipboard: () async => null);
d.addToEditList = (files) async => files.length;
d.setAutoAdd = (on) async {
s.download.addFinished = on;
d.refresh();
};
final t = d.addPage('https://vimeo.com/1')!..title = '영상';
await tester.pumpWidget(MaterialApp(home: DownloadsPage(d: d)));
Checkbox box() => tester.widget<Checkbox>(
find.descendant(of: find.byTooltip(RegExp('MKV 만들기의 동영상 목록에 자동으로')), matching: find.byType(Checkbox)));
expect(box().value, isFalse);
await tester.tap(find.text('완료 시 자동 동영상 추가'));
await tester.pump();
expect(s.download.addFinished, isTrue);
expect(box().value, isTrue);
// 다 받으면 목록에서 뺀다 (앱이 onFinished 에서 부름)
d.dropFromList(t);
await tester.pump();
expect(d.tasks, isEmpty);
expect(find.text('영상'), findsNothing);
d.dispose();
});
// 246 · P11 확인 (921): 영어 · 일본어는 글이 길어 체크가 아예 안 보였다 - 언어 · 폭마다
for (final lang in ['ko', 'en', 'ja']) {
for (final w in [800.0, 1200.0]) {
testWidgets('246: 다운로드 화면 ($lang, 폭 $w) - 버튼 줄의 글이 잘리거나 "전체 선택" 과 겹치지 않는다', (tester) async {
Map<String, String> dict(String code) =>
Map<String, String>.from(jsonDecode(File('assets/l10n/$code.json').readAsStringSync()) as Map);
setTranslations(lang, lang == 'ko' ? const {} : dict(lang), lang == 'ko' ? const {} : dict('en'));
addTearDown(() => setTranslations('ko', const {}));
tester.view.physicalSize = Size(w, 411);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final s = AppSettings();
final d = DownloadManager(backends: [TestDownloadBackend()], settings: () => s, readClipboard: () async => null);
addTearDown(d.dispose);
d.addToEditList = (files) async => files.length;
d.setAutoAdd = (on) async => s.download.addFinished = on;
d.addPage('https://vimeo.com/1')!.title = '영상';
await tester.pumpWidget(MaterialApp(home: DownloadsPage(d: d)));
await tester.pump();
expect(tester.takeException(), isNull);
final all = tester.getRect(find.text(tr('전체 선택')));
final auto = tester.getRect(find.text(tr('완료 시 자동 동영상 추가')));
expect(auto.overlaps(all), isFalse, reason: '$auto · $all');
expect([auto.left >= 0, auto.right <= w], [true, true], reason: '화면 안에 다 보임 $auto');
for (final t in ['일시정지', '재개', '다운로드 취소', '삭제', '완료 정리']) {
final r = tester.getRect(find.text(tr(t)).first);
expect([r.left >= 0, r.right <= w], [true, true], reason: '$t $r');
}
});
}
}
testWidgets('환경 설정 · 종료 버튼: 모든 화면에서 같은 자리', (tester) async {
tester.view.physicalSize = const Size(1500, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final dir = Directory.systemTemp.createTempSync('jj_pos_');
addTearDown(() => dir.deleteSync(recursive: true));
final c = plainController();
final d = DownloadManager(backends: [TestDownloadBackend()], settings: () => c.settings, readClipboard: () async => null);
final bm = BookmarksController(p.join(dir.path, 'bm.json'));
var exits = 0;
Future<(Offset, Offset, Offset, Offset)> at(Widget page) async {
await tester.pumpWidget(MaterialApp(
key: UniqueKey(),
builder: (context, child) =>
AppScope(controller: c, onExit: () => exits++, downloads: d, bookmarks: bm, child: child!),
home: page,
));
await tester.pump();
return (
tester.getCenter(find.byTooltip('환경 설정')),
tester.getCenter(find.byTooltip('종료')),
// 왼쪽 공통 버튼: JJ (홈) · 다운로드 목록
tester.getCenter(find.byTooltip(RegExp('^홈 화면'))),
tester.getCenter(find.byTooltip(RegExp('^다운로드 목록'))),
);
}
final home = await at(HomePage(c: c, downloads: d, bookmarks: bm, onExit: () => exits++));
final pages = {
'다운로드': await at(DownloadsPage(d: d)),
'환경 설정': await at(SettingsPage(c: c)),
'브라우저': await at(BrowserPage(
c: c, bookmarks: bm, downloads: d, viewBuilder: (h, url) => const ColoredBox(color: Colors.white))),
};
for (final e in pages.entries) {
expect(e.value, home, reason: '${e.key} 화면의 버튼 자리가 MKV 화면과 다릅니다');
}
// 오른쪽 위 구석
expect(home.$2.dx, greaterThan(1500 - 60));
expect(home.$2.dy, 28);
// 왼쪽 위 구석: JJ 아이콘 (홈)
expect(home.$3.dx, lessThan(40));
expect(home.$3.dy, 28);
// 브라우저에서 종료 버튼 → 앱의 종료 동작
await tester.tap(find.byTooltip('종료'));
expect(exits, 1);
d.dispose();
});
testWidgets('MKV 만들기: 체크가 없으면 보고 있는 동영상만, 보고 있는 것도 없으면 "전체 만들기 / 취소" 를 묻는다', (tester) async {
tester.view.physicalSize = const Size(1500, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final c = plainController()..ffmpegVersion = 'test';
c.videos.addAll([VideoItem(r'D:\v\a.mp4'), VideoItem(r'D:\v\b.mp4')]);
c.selected = null;
await tester.pumpWidget(MaterialApp(home: HomePage(c: c)));
await tester.tap(find.text('MKV 만들기'));
await tester.pumpAndSettle();
expect(find.textContaining('선택한 동영상이 없습니다'), findsOneWidget);
expect(find.textContaining('목록 전체 2개'), findsOneWidget);
await tester.tap(find.text('취소'));
await tester.pumpAndSettle();
expect(c.busy, isFalse); // 취소하면 아무것도 하지 않음
// 하나라도 고르면 묻지 않는다 (버튼 글에 개수)
c.toggleChecked(c.videos.last);
await tester.pump();
expect(find.text('MKV 만들기 (1)'), findsOneWidget);
});
testWidgets('왼쪽 공통 버튼: JJ (홈) · MKV 화면으로 · 뒤로 · 다운로드 목록', (tester) async {
tester.view.physicalSize = const Size(1500, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final dir = tempDirUntilEnd('jj_nav_');
final c = plainController();
final d = DownloadManager(backends: [TestDownloadBackend()], settings: () => c.settings, readClipboard: () async => null);
final bm = BookmarksController(p.join(dir.path, 'bm.json'));
await tester.pumpWidget(MaterialApp(
builder: (context, child) => AppScope(controller: c, downloads: d, bookmarks: bm, child: child!),
home: HomePage(c: c, downloads: d, bookmarks: bm),
));
IconButton btn(String tip) =>
tester.widget<IconButton>(find.ancestor(of: find.byTooltip(RegExp('^$tip')), matching: find.byType(IconButton)).first);
// MKV 화면: 뒤로 · MKV 는 "지금 여기" 라 꺼짐, 다운로드 목록 열기
expect(btn('뒤로').onPressed, isNull);
expect(find.byTooltip('MKV 화면 (지금 여기)'), findsOneWidget);
// 지금 화면 버튼 아래 밑줄 하나 (그 버튼 아래에)
final line = find.byKey(const ValueKey('nav-here-underline'));
expect(line, findsOneWidget);
final mkv = tester.getRect(find.byTooltip('MKV 화면 (지금 여기)'));
expect(tester.getCenter(line).dx, closeTo(mkv.center.dx, 2));
await tester.tap(find.byTooltip('다운로드 목록'));
await tester.pumpAndSettle();
expect(find.textContaining('다운로드 ('), findsOneWidget);
expect(find.byTooltip('다운로드 목록 (지금 여기)'), findsOneWidget);
// 다운로드 → 환경 설정 → MKV 화면으로 (한 번에 처음까지)
await tester.tap(find.byTooltip('환경 설정').first);
await tester.pumpAndSettle();
expect(find.text('환경 설정'), findsWidgets);
await tester.tap(find.byTooltip('MKV 화면으로'));
await tester.pumpAndSettle();
expect(find.byTooltip('MKV 화면 (지금 여기)'), findsOneWidget);
// 뒤로: 한 단계만
await tester.tap(find.byTooltip('다운로드 목록'));
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('뒤로'));
await tester.pumpAndSettle();
expect(find.byTooltip('MKV 화면 (지금 여기)'), findsOneWidget);
// 홈 화면을 웹 브라우저로 정하면 JJ 아이콘이 브라우저를 연다 (MKV 버튼은 그대로 있음)
c.settings.display.startScreen = 'browser';
await tester.tap(find.byTooltip('다운로드 목록'));
await tester.pumpAndSettle();
await tester.tap(find.byTooltip(RegExp('^홈 화면 \\(웹 브라우저\\)')));
// 화면 전환 (닫히는 화면 · 새 화면) 이 끝날 때까지. 브라우저는 계속 움직여서 pumpAndSettle 은 쓰지 않는다.
for (var i = 0; i < 6; i++) {
await tester.pump(const Duration(milliseconds: 200));
}
expect(find.byType(BrowserPage), findsOneWidget);
expect(find.byTooltip('MKV 화면으로'), findsOneWidget);
await tester.pumpWidget(const SizedBox());
d.dispose();
});
testWidgets('262: 끝나는 순간 줄의 ■ 자리에 오는 ⟳ 는 지금 묶음이 끝날 때까지 눌리지 않는다 (✕ 도 그 자리로 밀려오지 않음)', (tester) async {
tester.view.physicalSize = const Size(1600, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final tool = EndTool();
final dir = Directory.systemTemp.createTempSync('jj_batch262w_');
addTearDown(() => dir.deleteSync(recursive: true));
final files = [for (final n in ['a', 'b']) (File(p.join(dir.path, '$n.mp4'))..writeAsBytesSync([0])).path];
final c = AppController(PlatformServices(mediaTool: tool, storage: DesktopStorageService()));
late Future<void> building;
await tester.runAsync(() async {
await c.init();
c.settings.mkv.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;
await tester.pumpWidget(MaterialApp(home: HomePage(c: c)));
await tester.pump();
final stopAt = tester.getCenter(find.byTooltip('이 동영상만 취소'));
// a 가 끝나고 b 가 시작
await tester.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 tester.pump();
expect(a.status, JobStatus.done);
// ■ 를 누르려던 자리를 누른다
await tester.tapAt(stopAt);
await tester.pump();
expect(a.status, JobStatus.done, reason: '⟳ 로 대기가 되지 않음');
expect(c.videos, contains(a), reason: '✕ 로 빠지지 않음');
await tester.runAsync(() async {
tool.finish(b);
await building;
});
await tester.pump();
expect(c.logs.join('\n'), contains('성공 2 · 취소 0 · 실패 0 / 전체 2'));
// 누른 자리의 풍선 도움말 타이머 정리
await tester.pumpWidget(const SizedBox());
await tester.pump(const Duration(seconds: 2));
});
testWidgets('240: 여럿이 동시에 돌 때 ■ 는 묻는다 ([그대로 두기] · [모두 취소 (n개)]) · 목록 줄의 ■ 는 그 동영상만', (tester) async {
tester.view.physicalSize = const Size(1600, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final tool = HoldTool();
final dir = Directory.systemTemp.createTempSync('jj_batch240_');
addTearDown(() => dir.deleteSync(recursive: true));
final files = [
for (final n in ['a', 'b']) (File(p.join(dir.path, '$n.mp4'))..writeAsBytesSync([0])).path,
];
final c = AppController(PlatformServices(mediaTool: tool, storage: DesktopStorageService()));
late Future<void> building;
await tester.runAsync(() async {
await c.init();
c.settings.mkv.maxParallelJobs = 5;
await c.addVideos(files);
building = c.buildVideos([...c.videos]);
for (var i = 0; i < 200 && tool.runs.length < 2; i++) {
await Future<void>.delayed(const Duration(milliseconds: 10));
}
});
final [a, b] = c.videos;
expect([a.status, b.status], [JobStatus.running, JobStatus.running]);
await tester.pumpWidget(MaterialApp(home: HomePage(c: c)));
await tester.pump();
// ■ (위 막대): 둘이 돌고 남은 것은 없음 → 묻는다
await tester.tap(find.byTooltip('작업 취소').first);
await tester.pump(const Duration(milliseconds: 400)); // 도는 동안은 진행 막대가 움직여 pumpAndSettle 이 끝나지 않는다
expect(find.text('모두 취소 (2개)'), findsOneWidget);
await tester.tap(find.text('그대로 두기'));
await tester.pump(const Duration(milliseconds: 400)); // 도는 동안은 진행 막대가 움직여 pumpAndSettle 이 끝나지 않는다
expect(tool.runs.length, 2, reason: '[그대로 두기] 는 아무것도 취소하지 않음');
// 목록 줄의 ■: 그 동영상만
expect(find.byTooltip('이 동영상만 취소'), findsNWidgets(2));
await tester.tap(find.byTooltip('이 동영상만 취소').first);
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 100)));
await tester.pump();
expect([a.status, b.status], [JobStatus.cancelled, JobStatus.running]);
// 하나만 남으면 ■ 는 묻지 않고 바로 취소
await tester.tap(find.byTooltip('작업 취소').first);
await tester.runAsync(() => building);
await tester.pump();
expect(b.status, JobStatus.cancelled);
expect(find.text('모두 취소 (2개)'), findsNothing);
});
testWidgets('242: 폰 가로 (노트 10+ - 내비게이션 막대를 뺀 약 800×411) 에서 작업 중이어도 위 막대 버튼이 화면 크기 "100%" 를 덮지 않는다', (tester) async {
final w = double.parse(Platform.environment['JJ_W'] ?? '800');
tester.view.physicalSize = Size(w, 411);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final tool = HoldTool();
final dir = Directory.systemTemp.createTempSync('jj_batch242_');
addTearDown(() => dir.deleteSync(recursive: true));
final file = (File(p.join(dir.path, 'a.mp4'))..writeAsBytesSync([0])).path;
final c = AppController(PlatformServices(mediaTool: tool, storage: DesktopStorageService()));
late Future<void> building;
await tester.runAsync(() async {
await c.init();
await c.addVideos([file]);
c.selected = c.videos.first;
building = c.buildVideos([...c.videos]);
for (var i = 0; i < 200 && tool.runs.isEmpty; i++) {
await Future<void>.delayed(const Duration(milliseconds: 10));
}
});
expect(c.busy, isTrue);
await tester.pumpWidget(MaterialApp(home: HomePage(c: c)));
// 첫 그림에서 재어 보고 모자라면 다음 그림부터 두 줄 (첫 그림도 넘침 오류 없이 잘라 그림)
expect(tester.takeException(), isNull);
await tester.pump();
await tester.pump();
expect(tester.takeException(), isNull, reason: '두 줄로 바뀐 뒤에는 넘침 없음');
final pct = tester.getRect(find.text('${(c.settings.display.scale * 100).round()}%'));
for (final tip in ['작업 취소', '결과 폴더 열기: ${outputDirFor(file)}']) {
final f = find.byTooltip(tip);
expect(f, findsWidgets, reason: tip);
final r = tester.getRect(f.first);
expect(r.overlaps(pct), isFalse, reason: '$tip 버튼 $r 이 "100%" $pct 를 덮음');
}
c.cancel();
await tester.runAsync(() => building);
await tester.pump();
});
testWidgets('폰 세로 (노트 10+ 약 411×800): 여러 개를 만드는 동안 위 막대 진행은 "n/3 · 37%" 짧은 꼴 · [+ 동영상 추가] 를 가리지 않는다', (tester) async {
tester.view.physicalSize = const Size(411, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final tool = HoldTool();
final dir = Directory.systemTemp.createTempSync('jj_batch_portrait_');
addTearDown(() => dir.deleteSync(recursive: true));
final files = [for (final n in ['a', 'b', 'c']) (File(p.join(dir.path, '$n.mp4'))..writeAsBytesSync([0])).path];
final c = AppController(PlatformServices(mediaTool: tool, storage: DesktopStorageService()));
late Future<void> building;
await tester.runAsync(() async {
await c.init();
await c.addVideos(files);
c.selected = c.videos.first;
building = c.buildVideos([...c.videos]);
for (var i = 0; i < 200 && tool.runs.isEmpty; i++) {
await Future<void>.delayed(const Duration(milliseconds: 10));
}
});
expect(c.busy, isTrue);
expect(c.batchTotal, 3);
await tester.pumpWidget(MaterialApp(home: HomePage(c: c)));
await tester.pump();
await tester.pump();
// 위 막대를 잰 뒤에 진행률이 생긴다 (예전에는 이때 글이 넓어져 [+] 를 가렸다)
for (final v in c.videos.where((v) => v.status == JobStatus.running)) {
v.progress = 0.37;
}
await c.updateSettings((_) {});
await tester.pump();
expect(tester.takeException(), isNull);
final prog = find.textContaining(RegExp(r'^\d+/3 · 37%$'));
expect(prog, findsOneWidget);
final add = find.byTooltip(Platform.isAndroid ? '동영상 추가' : '동영상 추가 (탐색기에서 끌어다 놓아도 됩니다)');
expect(add, findsWidgets);
final r = tester.getRect(prog);
// [+] 는 옆으로 미는 줄 안에 있어 끝이 잘려 보일 수 있다 - 실제로 보이는 부분 (밀기 줄 안쪽) 이 진행 글과 겹치지 않아야 한다
for (final e in add.evaluate()) {
final a = tester.getRect(find.byWidget(e.widget).first);
final view = tester.getRect(find.ancestor(of: find.byWidget(e.widget).first, matching: find.byType(Scrollable)).first);
final seen = a.intersect(view);
expect(seen.width > 0 && seen.overlaps(r), isFalse, reason: '[+] 의 보이는 부분 $seen 이 진행 글 $r 와 겹침');
}
c.cancel();
await tester.runAsync(() => building);
await tester.pump();
});
}

View File

@ -0,0 +1,102 @@
/// MKV 묶음 · 다운로드 시험의 대역 (batch_test · batch_ui_test 가 같이 쓴다): 멈춰 두는 FFmpeg · 음성인식 · 번역 · 다운로드 엔진
library;
import 'dart:async';
import 'dart:io';
import 'package:jj_mkvmaker/app/app_controller.dart';
import 'package:jj_mkvmaker/core/download_detect.dart';
import 'package:jj_mkvmaker/core/models.dart';
import 'package:jj_mkvmaker/core/srt.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/ai_services.dart';
import 'package:jj_mkvmaker/services/downloader.dart';
import 'package:jj_mkvmaker/services/media_tool.dart';
import 'package:jj_mkvmaker/services/platform_services.dart';
import 'package:path/path.dart' as p;
class TestRecognizer implements SpeechRecognizer {
@override
void cancel() {}
final heard = <String>[];
@override
Future<List<Cue>> transcribe(String wavPath,
{required String modelPath, String language = 'auto', AiProgress? onProgress}) async {
heard.add(p.basename(wavPath));
return [Cue(const Duration(seconds: 1), const Duration(seconds: 2), 'Hello everyone, this is a test.')];
}
}
/// 240: 취소될 때까지 끝나지 않는 FFmpeg (여럿이 동시에 도는 상태를 만든다)
class HoldTool 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);
}
}
void _stop(Completer<void>? c) {
if (c != null && !c.isCompleted) c.completeError(MediaToolException('사용자가 취소했습니다.'));
}
@override
void cancel() => runs.values.toList().forEach(_stop);
@override
void cancelTagged(Object tag) => _stop(runs[tag]);
}
class TestTranslator implements Translator {
@override
Future<void> load(String modelDir) async {}
@override
Future<List<String>> translate(List<String> lines,
{required String source, required String target, AiProgress? onProgress}) async =>
[for (final l in lines) '[$target] $l'];
@override
void cancel() {}
@override
Future<void> dispose() async {}
}
class TestDownloadBackend implements DownloadBackend {
@override
DownloadKind get kind => DownloadKind.video;
@override
Future<(String, List<PlaylistEntry>)?> expandPlaylist(String url) async => null;
@override
Future<void> start(DownloadTask t, void Function() changed) async => t.state = DownloadState.downloading;
@override
Future<void> pause(DownloadTask t) async {}
@override
Future<void> cancel(DownloadTask t) async {}
@override
Future<void> shutdown() async {}
}
/// 262: [HoldTool] 과 같지만, 끝내면 (결과 이름으로 바꿀) 임시 출력 파일을 쓴다
class EndTool extends HoldTool {
final args = <Object?, List<String>>{};
@override
Future<void> runFfmpeg(List<String> a, {Duration? duration, ProgressCallback? onProgress, Object? tag}) async {
args[tag] = a;
await super.runFfmpeg(a, duration: duration, onProgress: onProgress, tag: tag);
File(a.last).writeAsStringSync('NEW');
}
/// [tag] 의 FFmpeg 를 성공으로 끝낸다
void finish(Object tag) => runs[tag]!.complete();
}
AppController plainController() =>
AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));