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> 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 ..download.root = dir.path ..network.mode = forced ? 'custom' : 'system' ..network.primary = '8.8.8.8' ..network.secondary = '8.8.4.4'; final g = NetGuard.instance ..config = (() => c.settings.network.config) ..log = log; await tester.runAsync(g.ensure); addTearDown(() async { c.settings.network.mode = 'system'; await g.ensure(); }); final d = DownloadManager(backends: [Aria2Backend(aria2c: aria2c)], settings: () => c.settings); await tester.pumpWidget(MaterialApp(home: DownloadsPage(d: d))); Future wait(int ms) async { for (var i = 0; i < ms ~/ 250; i++) { await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 250))); await tester.pump(); } } Future 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 = {}; 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 ?? ''); }