327 lines
13 KiB
Dart
327 lines
13 KiB
Dart
import 'dart:io';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:flutter_test/flutter_test.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/platform/windows/aria2_backend.dart';
|
|
import 'package:jj_mkvmaker/platform/windows/ytdlp_backend.dart';
|
|
import 'package:jj_mkvmaker/services/app_shell.dart';
|
|
import 'package:jj_mkvmaker/services/downloader.dart';
|
|
import 'package:jj_mkvmaker/ui/downloads_page.dart';
|
|
import 'package:path/path.dart' as p;
|
|
|
|
class _FakeBackend implements DownloadBackend {
|
|
@override
|
|
final DownloadKind kind;
|
|
final started = <String>[];
|
|
_FakeBackend(this.kind);
|
|
|
|
/// 재생목록 흉내 (null 이면 재생목록 아님)
|
|
(String, List<PlaylistEntry>)? playlist;
|
|
|
|
@override
|
|
Future<(String, List<PlaylistEntry>)?> expandPlaylist(String url) async => playlist;
|
|
|
|
@override
|
|
Future<void> start(DownloadTask t, void Function() changed) async {
|
|
started.add(t.source);
|
|
t.state = DownloadState.downloading;
|
|
changed();
|
|
}
|
|
|
|
@override
|
|
Future<void> pause(DownloadTask t) async => t.state = DownloadState.paused;
|
|
@override
|
|
Future<void> cancel(DownloadTask t) async => t.state = DownloadState.cancelled;
|
|
@override
|
|
Future<void> shutdown() async {}
|
|
}
|
|
|
|
final _tools = p.join(Directory.current.path, 'third_party', 'tools', 'windows');
|
|
|
|
void main() {
|
|
group('주소 판별', () {
|
|
test('YouTube · 마그넷 · torrent', () {
|
|
final links = detectLinks('''
|
|
보세요 https://www.youtube.com/watch?v=jNQXAC9IVRw&t=3s, 그리고
|
|
https://youtu.be/abc123XYZ_-).
|
|
magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&dn=ubuntu
|
|
http://example.com/files/linux.iso.torrent?x=1
|
|
https://example.com/page.html
|
|
중복 https://youtu.be/abc123XYZ_-
|
|
''');
|
|
expect(links.map((l) => '${l.kind.name}:${l.url}'), [
|
|
'video:https://www.youtube.com/watch?v=jNQXAC9IVRw&t=3s',
|
|
'video:https://youtu.be/abc123XYZ_-',
|
|
'torrent:magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&dn=ubuntu',
|
|
'torrent:http://example.com/files/linux.iso.torrent?x=1',
|
|
]);
|
|
expect(detectLinks('https://www.youtube.com/shorts/xyz789').single.kind, DownloadKind.video);
|
|
expect(detectLinks('그냥 글입니다'), isEmpty);
|
|
});
|
|
|
|
test('yt-dlp 진행 줄', () {
|
|
final pr = parseYtDlpProgress('[JJ] 45.3%| 2.10MiB/s|00:12')!;
|
|
expect(pr.percent, closeTo(0.453, 1e-9));
|
|
expect(pr.speed, '2.10MiB/s');
|
|
expect(pr.eta, '00:12');
|
|
expect(parseYtDlpProgress('[JJ] N/A%|Unknown B/s|NA')!.percent, isNull);
|
|
expect(parseYtDlpProgress('[download] 50%'), isNull);
|
|
final sized = parseYtDlpProgress('[JJ] 50.0%|1.00MiB/s|00:10|5000000|10000000.0')!;
|
|
expect([sized.received, sized.total], [5000000, 10000000]);
|
|
expect(parseYtDlpProgress('[JJ] 50.0%|1.00MiB/s|00:10|5000|NA')!.total, isNull);
|
|
});
|
|
|
|
test('영상 + 음성을 따로 받아도 하나의 진행률 · 크기', () {
|
|
YtDlpProgress p(int got, int all) => YtDlpProgress(got / all, '', '', received: got, total: all);
|
|
final t = YtDlpTotals()..expected = 1000; // 영상 900 + 음성 100
|
|
t.update(p(450, 900));
|
|
expect([t.received, t.total, t.progress], [450, 1000, 0.45]);
|
|
t.update(p(900, 900)); // 영상 다 받음: 100% 가 아니라 90%
|
|
expect(t.progress, 0.9);
|
|
t.nextFile();
|
|
t.update(p(50, 100));
|
|
expect([t.received, t.total, t.progress], [950, 1000, 0.95]);
|
|
t.update(p(100, 100));
|
|
expect(t.progress, 1.0);
|
|
|
|
// 전체 크기를 미리 모를 때: 지금 파일 기준, 추정치보다 커지면 큰 쪽
|
|
final u = YtDlpTotals();
|
|
u.update(const YtDlpProgress(0.3, '', ''));
|
|
expect([u.total, u.progress], [null, 0.3]);
|
|
u.update(p(300, 600));
|
|
expect([u.received, u.total, u.progress], [300, 600, 0.5]);
|
|
final small = YtDlpTotals()..expected = 500;
|
|
small.update(p(600, 800));
|
|
expect([small.total, small.progress], [800, 0.75]);
|
|
});
|
|
|
|
test('yt-dlp 인수: 진행률이 꺼지지 않게 (--print 는 조용한 모드를 켠다)', () {
|
|
final t = DownloadTask(id: '1', kind: DownloadKind.video, source: 'https://youtu.be/x', dir: r'D:\dl');
|
|
final args = YtDlpBackend(ytdlp: 'yt-dlp').argsFor(t);
|
|
expect(args, containsAll(['--no-quiet', '--progress', '--newline']));
|
|
expect(args.firstWhere((a) => a.startsWith('download:[JJ]')), contains('progress.downloaded_bytes'));
|
|
expect(args, contains('before_dl:[JJS]%(filesize,filesize_approx)s'));
|
|
});
|
|
|
|
test('크기 표시 글', () {
|
|
final t = DownloadTask(id: '1', kind: DownloadKind.video, source: 'x', dir: 'd');
|
|
expect(downloadSizeText(t), '');
|
|
t
|
|
..receivedBytes = 174063616
|
|
..totalBytes = 384827392;
|
|
expect(downloadSizeText(t), '166MB / 367MB');
|
|
t.totalBytes = null;
|
|
expect(downloadSizeText(t), '166MB');
|
|
t
|
|
..totalBytes = 384827392
|
|
..state = DownloadState.done;
|
|
expect(downloadSizeText(t), '367MB');
|
|
});
|
|
|
|
test('단축키 형식', () {
|
|
final (m1, k1) = parseHotkey('Ctrl+Shift+X')!;
|
|
expect([m1, k1], [['ctrl', 'shift'], 'X']);
|
|
final (m2, k2) = parseHotkey('alt + f5')!;
|
|
expect([m2, k2], [['alt'], 'F5']);
|
|
expect(parseHotkey('X'), isNull);
|
|
expect(parseHotkey('Ctrl+Ctrl+X'), isNull);
|
|
expect(parseHotkey('Ctrl+Space'), isNull);
|
|
});
|
|
});
|
|
|
|
test('설정 저장 · 불러오기', () async {
|
|
final dir = Directory.systemTemp.createTempSync('jj_set_');
|
|
addTearDown(() => dir.deleteSync(recursive: true));
|
|
final store = SettingsStore(p.join(dir.path, 'settings.json'));
|
|
expect((await store.load()).clipboardWatch, isTrue); // 파일 없으면 기본값
|
|
final s = AppSettings()
|
|
..mkvOutputRoot = r'D:\out'
|
|
..downloadRoot = r'D:\dl'
|
|
..clipboardWatch = false
|
|
..showHotkey = 'Alt+F9'
|
|
..askAiOptions = false
|
|
..encode = const EncodeSettings(codec: VideoCodecChoice.vp9, resolution: ResolutionChoice.p720)
|
|
..aiTargets = ['ko', 'fr'];
|
|
await store.save(s);
|
|
final l = await store.load();
|
|
expect([l.mkvOutputRoot, l.downloadRoot, l.clipboardWatch, l.showHotkey, l.askAiOptions],
|
|
[r'D:\out', r'D:\dl', false, 'Alt+F9', false]);
|
|
expect(l.encode.codec, VideoCodecChoice.vp9);
|
|
expect(l.encode.resolution, ResolutionChoice.p720);
|
|
expect(l.aiTargets, ['ko', 'fr']);
|
|
expect(l.ytDlpDir, p.join(r'D:\dl', 'jj_yt-dlp'));
|
|
expect(l.aria2Dir, p.join(r'D:\dl', 'jj_aria2'));
|
|
});
|
|
|
|
group('다운로드 관리자', () {
|
|
late _FakeBackend video, torrent;
|
|
late DownloadManager d;
|
|
late AppSettings settings;
|
|
String clip = 'https://youtu.be/already-there';
|
|
|
|
setUp(() {
|
|
video = _FakeBackend(DownloadKind.video);
|
|
torrent = _FakeBackend(DownloadKind.torrent);
|
|
settings = AppSettings()..downloadRoot = r'D:\dl';
|
|
clip = 'https://youtu.be/already-there';
|
|
d = DownloadManager(
|
|
backends: [video, torrent], settings: () => settings, readClipboard: () async => clip);
|
|
});
|
|
tearDown(() => d.dispose());
|
|
|
|
test('클립보드: 시작 전 내용 무시, 새 주소만 한 번 추가', () async {
|
|
await d.startClipboardWatch();
|
|
d.stopClipboardWatch(); // 타이머 대신 직접 확인
|
|
expect(await d.checkClipboard(), isEmpty);
|
|
|
|
clip = '이거 받아 https://www.youtube.com/watch?v=AAA111';
|
|
final added = await d.checkClipboard();
|
|
expect(added.single.kind, DownloadKind.video);
|
|
expect(added.single.dir, p.join(r'D:\dl', 'jj_yt-dlp'));
|
|
expect(await d.checkClipboard(), isEmpty); // 같은 내용
|
|
clip = 'https://www.youtube.com/watch?v=AAA111'; // 내용은 다르지만 같은 주소
|
|
expect(await d.checkClipboard(), isEmpty);
|
|
|
|
clip = 'magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567';
|
|
final t = (await d.checkClipboard()).single;
|
|
expect(t.kind, DownloadKind.torrent);
|
|
expect(t.dir, p.join(r'D:\dl', 'jj_aria2'));
|
|
expect(video.started, ['https://www.youtube.com/watch?v=AAA111']);
|
|
expect(torrent.started, hasLength(1));
|
|
|
|
settings.clipboardWatch = false;
|
|
clip = 'https://youtu.be/BBB222';
|
|
expect(await d.checkClipboard(), isEmpty);
|
|
});
|
|
|
|
test('선택 · 일시정지 · 재개 · 취소 · 삭제 · 정리', () async {
|
|
final a = d.add('https://youtu.be/A1')!;
|
|
final b = d.add('https://youtu.be/B2')!;
|
|
final c = d.add('magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567')!;
|
|
await Future<void>.delayed(Duration.zero);
|
|
expect(d.add('https://youtu.be/A1'), isNull);
|
|
expect(d.activeCount, 3);
|
|
|
|
d.toggle(a);
|
|
d.toggle(b);
|
|
await d.pauseSelected();
|
|
expect([a.state, b.state, c.state],
|
|
[DownloadState.paused, DownloadState.paused, DownloadState.downloading]);
|
|
await d.resumeSelected();
|
|
await Future<void>.delayed(Duration.zero);
|
|
expect(a.state, DownloadState.downloading);
|
|
|
|
d.selectNone();
|
|
d.toggle(b);
|
|
await d.cancelSelected();
|
|
expect(b.state, DownloadState.cancelled);
|
|
expect(d.tasks, contains(b)); // 취소는 목록에 남음
|
|
|
|
c.state = DownloadState.done;
|
|
d.cleanupFinished(); // 완료·취소 정리
|
|
expect(d.tasks, [a]);
|
|
|
|
d.selectAll();
|
|
await d.removeSelected();
|
|
expect(d.tasks, isEmpty);
|
|
expect(d.activeCount, 0);
|
|
});
|
|
});
|
|
|
|
group('aria2 (실제 실행)', () {
|
|
late HttpServer server;
|
|
late Directory dir;
|
|
final data = Uint8List.fromList(List.generate(3 * 1024 * 1024, (i) => i % 251));
|
|
var slow = false;
|
|
|
|
setUp(() async {
|
|
dir = Directory.systemTemp.createTempSync('jj_aria_');
|
|
server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
|
server.listen((req) async {
|
|
req.response.headers
|
|
..contentType = ContentType.binary
|
|
..contentLength = data.length;
|
|
if (!slow) {
|
|
req.response.add(data);
|
|
} else {
|
|
for (var i = 0; i < data.length; i += 32 * 1024) {
|
|
req.response.add(data.sublist(i, (i + 32 * 1024).clamp(0, data.length)));
|
|
await Future<void>.delayed(const Duration(milliseconds: 100));
|
|
}
|
|
}
|
|
await req.response.close().catchError((_) {});
|
|
});
|
|
});
|
|
tearDown(() async {
|
|
await server.close(force: true);
|
|
try {
|
|
dir.deleteSync(recursive: true);
|
|
} catch (_) {}
|
|
});
|
|
|
|
Future<void> waitFor(bool Function() ok) async {
|
|
for (var i = 0; i < 100 && !ok(); i++) {
|
|
await Future<void>.delayed(const Duration(milliseconds: 100));
|
|
}
|
|
}
|
|
|
|
test('받기 완료 · 일시정지 · 재개 · 취소(파일 삭제)', () async {
|
|
final exe = p.join(_tools, 'aria2c.exe');
|
|
if (!File(exe).existsSync()) return markTestSkipped('aria2c 없음');
|
|
final b = Aria2Backend(aria2c: exe);
|
|
addTearDown(b.shutdown);
|
|
|
|
// 1. 빠르게 받기 → 완료
|
|
slow = false;
|
|
final t1 = DownloadTask(
|
|
id: '1', kind: DownloadKind.torrent, source: 'http://127.0.0.1:${server.port}/a.bin', dir: dir.path);
|
|
await b.start(t1, () {});
|
|
await waitFor(() => t1.state == DownloadState.done);
|
|
expect(t1.state, DownloadState.done, reason: t1.error);
|
|
expect(File(p.join(dir.path, 'a.bin')).readAsBytesSync(), data);
|
|
|
|
// 2. 느리게 받기 → 일시정지 → 재개 → 취소
|
|
slow = true;
|
|
final t2 = DownloadTask(
|
|
id: '2', kind: DownloadKind.torrent, source: 'http://127.0.0.1:${server.port}/b.bin', dir: dir.path);
|
|
await b.start(t2, () {});
|
|
await waitFor(() => (t2.progress ?? 0) > 0.05);
|
|
expect(t2.state, DownloadState.downloading);
|
|
await b.pause(t2);
|
|
expect(t2.state, DownloadState.paused);
|
|
await b.start(t2, () {});
|
|
expect(t2.state, DownloadState.downloading);
|
|
await waitFor(() => t2.files.isNotEmpty);
|
|
await b.cancel(t2);
|
|
expect(t2.state, DownloadState.cancelled);
|
|
expect(File(p.join(dir.path, 'b.bin')).existsSync(), isFalse);
|
|
expect(File(p.join(dir.path, 'b.bin.aria2')).existsSync(), isFalse);
|
|
}, timeout: const Timeout(Duration(minutes: 2)));
|
|
});
|
|
|
|
test('yt-dlp (실제 YouTube, JJ_NET_TESTS=1 일 때만)', () async {
|
|
final exe = p.join(_tools, 'yt-dlp.exe');
|
|
if (Platform.environment['JJ_NET_TESTS'] != '1' || !File(exe).existsSync()) {
|
|
return markTestSkipped('인터넷 테스트 꺼짐');
|
|
}
|
|
final dir = Directory.systemTemp.createTempSync('jj_yt_');
|
|
addTearDown(() => dir.deleteSync(recursive: true));
|
|
final b = YtDlpBackend(
|
|
ytdlp: exe, ffmpegDir: r'M:\jj_CapCut\tools\ffmpeg\bin', deno: p.join(_tools, 'deno.exe'));
|
|
final t = DownloadTask(
|
|
id: 'y', kind: DownloadKind.video, source: 'https://www.youtube.com/watch?v=jNQXAC9IVRw', dir: dir.path);
|
|
final progress = <double>[];
|
|
await b.start(t, () {
|
|
if (t.progress != null) progress.add(t.progress!);
|
|
});
|
|
expect(t.state, DownloadState.done, reason: t.error);
|
|
expect(t.title, 'Me at the zoo');
|
|
expect(t.files.where((f) => File(f).existsSync()), isNotEmpty);
|
|
expect(progress, isNotEmpty);
|
|
}, timeout: const Timeout(Duration(minutes: 3)));
|
|
}
|