JJ_MKVMaker/integration_test/magnet_real_test.dart

202 lines
9.2 KiB
Dart

import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:crypto/crypto.dart';
import 'package:flutter/material.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/download_manager.dart';
import 'package:jj_mkvmaker/app/net_guard.dart';
import 'package:jj_mkvmaker/core/download_detect.dart' show DownloadKind;
import 'package:jj_mkvmaker/platform/windows/aria2_backend.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/downloads_page.dart';
import 'package:path/path.dart' as p;
import 'support/temp_dir.dart';
/// 사용자 질문 (10/20) "마그넷 테스트 된 건가" - 지금까지는 단위 시험뿐이었다. 실제 마그넷을 앱의 다운로드 관리자 (aria2c) 로 받는다.
/// 공개된 합법 배포 파일: Debian 공식 netinst ISO 의 토렌트 (cdimage.debian.org). 정보 해시는 공식 .torrent 에서 계산해
/// 마그넷을 만들고 (판이 바뀌어도 맞게), 끝나면 공식 SHA256SUMS 와 맞춘다.
/// - DNS 시스템: 끝까지 받기 · 진행률 · 속도 · 일시 정지 / 재개 · SHA256
/// - DNS 강제 (8.8.8.8 · 8.8.4.4, 앱 안 프록시): 받기 시작 · 진행률 · 일시 정지 / 재개 · 취소 (받던 파일 지움)
/// 인터넷 · 디스크 (약 800MB) 를 쓰므로 JJ_REAL_MAGNET=1 일 때만:
/// flutter test integration_test\magnet_real_test.dart -d windows (PowerShell: $env:JJ_REAL_MAGNET='1')
/// 받은 파일은 시험 폴더 (%TEMP%\jj_magnet_*) 에 두고 끝나면 지운다.
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
const base = 'https://cdimage.debian.org/debian-cd/current/amd64/bt-cd/';
for (final forced in [false, true]) {
testWidgets('마그넷 실제 받기 (DNS ${forced ? '강제 8.8.8.8' : '시스템'})', (tester) async {
if (Platform.environment['JJ_REAL_MAGNET'] != '1') return markTestSkipped('JJ_REAL_MAGNET=1 일 때만 (인터넷 · 약 800MB)');
final aria2c = p.join(Directory.current.path, 'third_party', 'tools', 'windows', 'aria2c.exe');
expect(File(aria2c).existsSync(), isTrue, reason: '앱에 든 aria2c (tool\\fetch_tools.ps1)');
final dir = testTempDir('jj_magnet_');
void log(String s) => debugPrint('MAGNET ${forced ? 'DNS강제' : 'DNS시스템'} ${DateTime.now().toIso8601String().substring(11, 19)} $s');
// 공식 토렌트 · SHA256 (판 이름은 목록에서)
late Uint8List torrent;
late String isoName, want;
await tester.runAsync(() async {
final http = HttpClient();
Future<List<int>> get(String url) async {
final res = await (await http.getUrl(Uri.parse(url))).close();
expect(res.statusCode, 200, reason: url);
return [for (final c in await res.toList()) ...c];
}
final index = utf8.decode(await get(base));
final name = RegExp(r'href="(debian-[0-9.]+-amd64-netinst\.iso)\.torrent"').firstMatch(index)![1]!;
isoName = name;
torrent = Uint8List.fromList(await get('$base$name.torrent'));
final sums = utf8.decode(await get('${base}SHA256SUMS'));
want = RegExp('^([0-9a-f]{64})\\s+${RegExp.escape(name)}\$', multiLine: true).firstMatch(sums)![1]!;
http.close();
});
final (infoHash, announce) = _torrentInfo(torrent);
final magnet = 'magnet:?xt=urn:btih:$infoHash&dn=${Uri.encodeComponent(isoName)}&tr=${Uri.encodeComponent(announce)}';
log('$isoName · $infoHash · 공식 SHA256 $want');
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..downloadRoot = dir.path
..dnsMode = forced ? 'custom' : 'system'
..dnsPrimary = '8.8.8.8'
..dnsSecondary = '8.8.4.4';
final g = NetGuard.instance
..config = (() => c.settings.dnsConfig)
..log = log;
await tester.runAsync(g.ensure);
addTearDown(() async {
c.settings.dnsMode = 'system';
await g.ensure();
});
final d = DownloadManager(backends: [Aria2Backend(aria2c: aria2c)], settings: () => c.settings);
await tester.pumpWidget(MaterialApp(home: DownloadsPage(d: d)));
Future<void> wait(int ms) async {
for (var i = 0; i < ms ~/ 250; i++) {
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 250)));
await tester.pump();
}
}
Future<bool> until(bool Function() ok, {required Duration within}) async {
final end = DateTime.now().add(within);
while (!ok() && DateTime.now().isBefore(end)) {
await wait(1000);
}
return ok();
}
// 받기 시작 (메타데이터 → 실제 받기) · 진행률 · 속도
final t = d.add(magnet);
expect(t, isNotNull);
expect(t!.kind, DownloadKind.torrent);
final speeds = <String>{};
final started = await until(() {
if (t.speed.isNotEmpty) speeds.add(t.speed);
return (t.receivedBytes ?? 0) >= 8 << 20 || t.state == DownloadState.failed;
}, within: const Duration(minutes: 5));
log('시작: ${t.state} ${t.receivedBytes}/${t.totalBytes} 속도 $speeds 오류 ${t.error}');
expect(started && t.state == DownloadState.downloading, isTrue, reason: '8MB 를 5분 안에 받지 못함: ${t.state} ${t.error}');
expect(t.title, isoName, reason: '메타데이터를 받으면 이름이 바뀜');
expect(t.progress, greaterThan(0));
expect(t.totalBytes, greaterThan(100 << 20));
expect(speeds, isNotEmpty, reason: '속도가 보임');
expect(find.text(isoName), findsWidgets, reason: '다운로드 화면 줄');
// 일시 정지: 받은 크기가 늘지 않는다
await tester.runAsync(() => d.pause([t]));
await wait(3000);
final paused = t.receivedBytes ?? 0;
final onDisk = t.files.where((f) => File(f).existsSync()).map((f) => File(f).lengthSync()).fold(0, (a, b) => a + b);
await wait(5000);
log('일시 정지: ${t.state} $paused');
expect(t.state, DownloadState.paused);
expect(t.receivedBytes ?? 0, paused);
expect(t.files.where((f) => File(f).existsSync()).map((f) => File(f).lengthSync()).fold(0, (a, b) => a + b), onDisk,
reason: '멈춘 동안 파일이 커지지 않음');
// 재개: 이어서 받는다 (처음부터가 아니라)
await tester.runAsync(() => d.resume([t]));
final resumed = await until(() => (t.receivedBytes ?? 0) >= paused + (4 << 20), within: const Duration(minutes: 3));
log('재개: ${t.state} ${t.receivedBytes}');
expect(resumed, isTrue, reason: '재개 뒤 4MB 더 받지 못함');
if (!forced) {
// 끝까지 · 공식 SHA256
final done = await until(() => t.state == DownloadState.done || t.state == DownloadState.failed,
within: const Duration(minutes: 30));
log('끝: ${t.state} ${t.files}');
expect(done && t.state == DownloadState.done, isTrue, reason: '${t.state} ${t.error}');
final iso = t.files.firstWhere((f) => f.endsWith(isoName));
final got = await tester.runAsync(() async => '${await sha256.bind(File(iso).openRead()).first}');
log('SHA256 $got');
expect(got, want, reason: '공식 SHA256SUMS 와 같음');
} else {
// 취소: 받던 파일 (와 .aria2) 이 지워진다
final files = [...t.files];
await tester.runAsync(() => d.cancel([t]));
await wait(2000);
log('취소: ${t.state} 남은 파일 ${files.where((f) => File(f).existsSync() || File('$f.aria2').existsSync()).toList()}');
expect(t.state, DownloadState.cancelled);
for (final f in files) {
expect(File(f).existsSync() || File('$f.aria2').existsSync(), isFalse, reason: '$f 지워짐');
}
}
await tester.runAsync(d.shutdown);
await tester.pumpWidget(const SizedBox());
await wait(500);
}, timeout: const Timeout(Duration(minutes: 45)));
}
}
/// .torrent (bencode) 에서 정보 해시 (info 사전 바이트의 SHA-1) 와 첫 tracker
(String, String) _torrentInfo(Uint8List b) {
var i = 0;
String str() {
final colon = b.indexOf(58, i); // ':'
final n = int.parse(ascii.decode(b.sublist(i, colon)));
final s = latin1.decode(b.sublist(colon + 1, colon + 1 + n));
i = colon + 1 + n;
return s;
}
void skip() {
final c = b[i];
if (c == 0x69) {
i = b.indexOf(0x65, i) + 1; // i…e
} else if (c == 0x6c || c == 0x64) {
i++;
while (b[i] != 0x65) {
if (c == 0x64) str();
skip();
}
i++;
} else {
str();
}
}
String? hash, announce;
i = 1; // 맨 위 사전 'd'
while (b[i] != 0x65) {
final key = str();
final start = i;
if (key == 'announce') {
announce = utf8.decode(latin1.encode(str()));
continue;
}
skip();
if (key == 'info') hash = '${sha1.convert(b.sublist(start, i))}';
}
return (hash!, announce ?? '');
}