From 197ba047c4f34a6bbcd003760fd33730d27c5fde Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:01:13 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A7=88=EA=B7=B8=EB=84=B7=20=EC=A4=84:=20?= =?UTF-8?q?=EB=A9=94=ED=83=80=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=A0=84?= =?UTF-8?q?=EC=97=90=EB=8F=84=20dn=20=EC=9D=B4=EB=A6=84=20=C2=B7=20?= =?UTF-8?q?=EB=A9=88=EC=B6=98=20=EC=A4=84=EC=9D=80=20=EB=A7=89=EB=8C=80?= =?UTF-8?q?=EA=B0=80=20=EC=9B=80=EC=A7=81=EC=9D=B4=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EA=B3=A0=20=EC=A3=BC=ED=99=A9=20"=EB=A9=88=EC=B6=A4"=20(?= =?UTF-8?q?=EA=B2=80=ED=86=A0=20=EB=8C=80=EA=B8=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 관리자 지시 (10/20, 28 이 next5 6b60f34 확인에서 찾음): 1. 마그넷에 이름 (dn=…) 이 있으면 메타데이터를 받기 전에도 줄 제목에 그 이름 (magnetName: 주소 글 풀기 · + 는 빈칸 · 줄바꿈 · 제어 문자는 빈칸, 없으면 예전처럼 주소). 메타데이터를 받으면 토렌트 이름으로 바뀌는 것은 그대로. 2. 멈춘 줄 (주황 "트래커 · 피어에 닿지 않습니다") 의 진행 막대: 움직이는 "준비 중…" 대신 멈춘 막대 · 주황 · "멈춤" (이미 있는 번역 "Stopped" · "停止" · "已停止"). 멈춤이 풀리면 원래대로. 시험: downloads_ui_test (magnetName · 줄 제목 · 멈춤 막대 value 0 · pumpAndSettle 이 끝남 = 움직이지 않음). 캡처: integration_test/windows_magnet_shot_test (ko · en · ja × 412 · 960, 넘침 0) → release\test\shots_magnet_ui. Co-Authored-By: Claude Opus 5.5 --- .../windows_magnet_shot_test.dart | 92 +++++++++++++++++++ lib/core/download_detect.dart | 20 ++++ lib/services/downloader.dart | 2 +- lib/ui/downloads_page.dart | 13 ++- test/downloads_ui_test.dart | 69 ++++++++++++++ 5 files changed, 192 insertions(+), 4 deletions(-) create mode 100644 integration_test/windows_magnet_shot_test.dart diff --git a/integration_test/windows_magnet_shot_test.dart b/integration_test/windows_magnet_shot_test.dart new file mode 100644 index 0000000..ce11da5 --- /dev/null +++ b/integration_test/windows_magnet_shot_test.dart @@ -0,0 +1,92 @@ +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/download_manager.dart'; +import 'package:jj_mkvmaker/app/i18n_controller.dart'; +import 'package:jj_mkvmaker/app/settings.dart'; +import 'package:jj_mkvmaker/core/download_detect.dart'; +import 'package:jj_mkvmaker/l10n/tr.dart'; +import 'package:jj_mkvmaker/services/downloader.dart'; +import 'package:jj_mkvmaker/ui/downloads_page.dart'; +import 'package:jj_mkvmaker/ui/theme.dart'; +import 'package:path/path.dart' as p; + +/// 이름을 바꾸지 않는 가짜 백엔드 (아무것도 받지 않음 - 줄의 상태는 시험이 정한다) +class _Quiet implements DownloadBackend { + @override + final DownloadKind kind; + _Quiet(this.kind); + @override + Future<(String, List)?> expandPlaylist(String url) async => null; + @override + Future start(DownloadTask t, void Function() changed) async { + t + ..state = DownloadState.downloading + ..progress = null; + changed(); + } + + @override + Future pause(DownloadTask t) async => t.state = DownloadState.paused; + @override + Future cancel(DownloadTask t) async => t.state = DownloadState.cancelled; + @override + Future shutdown() async {} +} + +/// 관리자 지시 (10/20, 28 확인): 마그넷 줄 - 메타데이터 전에도 dn 이름 · 멈춘 줄의 막대는 움직이지 않고 주황 "멈춤". +/// 크기 412 · 960, 언어 ko · en · ja 로 찍는다 (가짜 백엔드 - 아무것도 받지 않음). +/// 실행: flutter test integration_test\windows_magnet_shot_test.dart -d windows (JJ_SHOT_DIR 에 PNG) +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + final out = Platform.environment['JJ_SHOT_DIR'] ?? p.join(Directory.systemTemp.path, 'jj_shots_magnet'); + const h = 'magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef0123456'; + for (final lang in ['ko', 'en', 'ja']) { + for (final (w, hh) in [(412, 900), (960, 600)]) { + testWidgets('마그넷 줄 $lang ${w}x$hh', (t) async { + t.view.physicalSize = Size(w.toDouble(), hh.toDouble()); + t.view.devicePixelRatio = 1; + addTearDown(t.view.reset); + await t.runAsync(() => i18n.apply(lang, save: false)); + addTearDown(() => i18n.apply('ko', save: false)); + final d = DownloadManager( + backends: [_Quiet(DownloadKind.video), _Quiet(DownloadKind.torrent)], + settings: () => AppSettings()..downloadRoot = r'D:\dl', + readClipboard: () async => null, + ); + // ① 메타데이터를 받는 중 (dn 이름 · 움직이는 "준비 중…") ② 멈춤 (이유 후보 · [다시 시도]) ③ 이름이 없는 마그넷 + d.add('${h}1&dn=${Uri.encodeQueryComponent('[시험] 아주 긴 이름의 토렌트 묶음 - Big Buck Bunny 1080p (2008) 한글 자막 포함')}&tr=udp%3A%2F%2Ft.example%3A80'); + d.add('${h}2&dn=Ubuntu+24.04.1+Desktop+amd64&tr=udp%3A%2F%2Ft.example%3A80'); + d.add('${h}3'); + final stalled = d.tasks.firstWhere((x) => x.title.startsWith('Ubuntu')); + stalled.extra + ..['stalled'] = true + ..['stallSince'] = DateTime.now().millisecondsSinceEpoch - 75000 + ..['stallReasons'] = ['dns', 'firewall']; + d.useSystemDns = () async {}; + final shotKey = GlobalKey(); + await t.pumpWidget(RepaintBoundary( + key: shotKey, + child: MaterialApp(theme: buildTheme(), debugShowCheckedModeBanner: false, home: DownloadsPage(d: d)), + )); + for (var i = 0; i < 6; i++) { + await t.runAsync(() => Future.delayed(const Duration(milliseconds: 60))); + await t.pump(const Duration(milliseconds: 60)); + } + expect(t.takeException(), isNull, reason: '넘침'); + expect(find.textContaining('[시험] 아주 긴 이름'), findsOneWidget); + expect(find.textContaining('Ubuntu 24.04.1 Desktop amd64'), findsOneWidget); + expect(find.text(tr('멈춤')), findsOneWidget); + final b = shotKey.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final img = await t.runAsync(() => b.toImage()); + final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png)); + Directory(out).createSync(recursive: true); + File(p.join(out, 'magnet_${lang}_$w.png')).writeAsBytesSync(bytes!.buffer.asUint8List()); + }); + } + } +} diff --git a/lib/core/download_detect.dart b/lib/core/download_detect.dart index 5a97e6d..1c4ecad 100644 --- a/lib/core/download_detect.dart +++ b/lib/core/download_detect.dart @@ -38,6 +38,26 @@ final _youtube = RegExp( final _magnet = RegExp(r'magnet:\?xt=urn:bt[im]h:[^\s<>"]+', caseSensitive: false); final _torrentUrl = RegExp(r'https?://[^\s<>"]+\.torrent(?:\?[^\s<>"]*)?', caseSensitive: false); +/// 마그넷 주소의 이름 (dn=…, 주소 글 풀기 · + 는 빈칸). 마그넷이 아니거나 이름이 없으면 null. +/// 관리자 지시 (10/20, 28 확인): 메타데이터를 받기 전에도 줄 제목에 이 이름을 보인다 (마그넷 주소 전체 대신) +String? magnetName(String url) { + if (!url.toLowerCase().startsWith('magnet:?')) return null; + for (final part in url.substring(8).split('&')) { + final i = part.indexOf('='); + if (i <= 0 || part.substring(0, i).toLowerCase() != 'dn') continue; + String name; + try { + name = Uri.decodeQueryComponent(part.substring(i + 1)); + } catch (_) { + name = part.substring(i + 1); // 잘못 적힌 % 는 그대로 + } + // 줄바꿈 · 제어 문자는 빈칸으로 (한 줄 제목) + name = name.replaceAll(RegExp(r'[\x00-\x1f\x7f]+'), ' ').trim(); + if (name.isNotEmpty) return name; + } + return null; +} + /// 클립보드 글에서 다운로드할 주소 찾기 (중복 제거, 나온 순서대로) List detectLinks(String text) { final found = {}; diff --git a/lib/services/downloader.dart b/lib/services/downloader.dart index c9f1ca6..d60574f 100644 --- a/lib/services/downloader.dart +++ b/lib/services/downloader.dart @@ -29,7 +29,7 @@ class DownloadTask { final Map extra = {}; DownloadTask({required this.id, required this.kind, required this.source, required this.dir}) - : title = source; + : title = magnetName(source) ?? source; // 마그넷은 메타데이터 전에도 dn 이름으로 (받으면 토렌트 이름으로 바뀜) bool get unfinished => state == DownloadState.queued || state == DownloadState.downloading || state == DownloadState.paused; diff --git a/lib/ui/downloads_page.dart b/lib/ui/downloads_page.dart index 8132ff7..218ce28 100644 --- a/lib/ui/downloads_page.dart +++ b/lib/ui/downloads_page.dart @@ -400,7 +400,11 @@ class DownloadProgressBar extends StatelessWidget { Widget build(BuildContext context) { final done = t.state == DownloadState.done; final value = done ? 1.0 : t.progress; + // 관리자 지시 (10/20, 28 확인): 멈춘 마그넷 · 토렌트 (줄의 주황 "트래커 · 피어에 닿지 않습니다") 는 막대를 움직이지 않고 + // 주황으로 "멈춤" - 움직이는 "준비 중…" 이면 받는 중인 것처럼 보였다 + final stalled = t.state == DownloadState.downloading && t.extra['stalled'] == true; final color = switch (t.state) { + _ when stalled => Colors.orangeAccent, DownloadState.done => JjColors.success, DownloadState.failed => JjColors.danger, DownloadState.paused => Colors.amber, @@ -414,7 +418,9 @@ class DownloadProgressBar extends StatelessWidget { ? tr('실패') // 136: 실패한 것에 "준비 중…" 이 남지 않게 (작은 다운로드 상자 등) : t.state == DownloadState.queued || t.extra['waiting'] == true ? tr('대기 중') - : tr('준비 중…')) + : stalled + ? tr('멈춤') + : tr('준비 중…')) : '${(value * 100).toStringAsFixed(1)}%${size.isEmpty ? '' : ' · $size'}'; return ClipRRect( borderRadius: BorderRadius.circular(4), @@ -423,9 +429,10 @@ class DownloadProgressBar extends StatelessWidget { child: Stack(fit: StackFit.expand, children: [ LinearProgressIndicator( // 진행률을 모를 때(받기 준비 · 마그넷 정보 받는 중)는 움직이는 막대 - value: value ?? (t.state == DownloadState.downloading ? null : 0), + value: value ?? (t.state == DownloadState.downloading && !stalled ? null : 0), minHeight: height, - backgroundColor: JjColors.panelHigh, + // 멈춘 줄은 빈 막대도 주황 기운으로 (회색 빈 막대면 그냥 시작 전처럼 보임) + backgroundColor: stalled ? Colors.orangeAccent.withValues(alpha: 0.28) : JjColors.panelHigh, color: color.withValues(alpha: 0.85), ), Center( diff --git a/test/downloads_ui_test.dart b/test/downloads_ui_test.dart index 649667a..e863a1b 100644 --- a/test/downloads_ui_test.dart +++ b/test/downloads_ui_test.dart @@ -30,6 +30,18 @@ class _Backend implements DownloadBackend { Future shutdown() async {} } +/// 이름을 바꾸지 않는 백엔드 (마그넷 메타데이터를 아직 받지 못한 상태) +class _Quiet extends _Backend { + _Quiet(super.kind); + @override + Future start(DownloadTask t, void Function() changed) async { + t + ..state = DownloadState.downloading + ..progress = null; + changed(); + } +} + void main() { DownloadManager make() => DownloadManager( backends: [_Backend(DownloadKind.video), _Backend(DownloadKind.torrent)], @@ -178,4 +190,61 @@ void main() { expect(find.textContaining('대기 중'), findsWidgets); expect(find.textContaining('받는 중'), findsNothing); }); + + test('관리자 지시 (10/20): 마그넷 이름 (dn) - 주소 글 풀기 · + 는 빈칸 · 없거나 마그넷이 아니면 null', () { + const h = 'magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567'; + expect(magnetName('$h&dn=Ubuntu+24.04%20Desktop&tr=udp%3A%2F%2Ft.example%3A80'), 'Ubuntu 24.04 Desktop'); + expect(magnetName('$h&DN=%ED%95%9C%EA%B8%80%20%EC%9D%B4%EB%A6%84'), '한글 이름'); + expect(magnetName('magnet:?dn=first&xt=urn:btih:00&dn=second'), 'first', reason: '처음 것'); + expect(magnetName('$h&dn=bad%zz'), 'bad%zz', reason: '잘못 적힌 % 는 그대로'); + expect(magnetName('$h&dn=a%0Ab'), 'a b', reason: '줄바꿈은 빈칸 (한 줄 제목)'); + expect(magnetName('$h&dn='), isNull); + expect(magnetName('$h&dn=%20+'), isNull); + expect(magnetName(h), isNull); + expect(magnetName('https://example.com/?dn=x'), isNull); + final t = DownloadTask(id: '1', kind: DownloadKind.torrent, source: '$h&dn=Big+Buck+Bunny', dir: r'D:\dl'); + expect(t.title, 'Big Buck Bunny'); + expect(DownloadTask(id: '2', kind: DownloadKind.torrent, source: h, dir: r'D:\dl').title, h, reason: '이름이 없으면 주소 그대로'); + }); + + testWidgets('관리자 지시 (10/20): 마그넷 줄 - 메타데이터 전에도 dn 이름 · 멈추면 막대가 움직이지 않고 주황 "멈춤"', (tester) async { + tester.view.physicalSize = const Size(1400, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final d = DownloadManager( + backends: [_Quiet(DownloadKind.video), _Quiet(DownloadKind.torrent)], + settings: () => AppSettings()..downloadRoot = r'D:\dl', + readClipboard: () async => null, + ); + await tester.pumpWidget(MaterialApp(home: DownloadsPage(d: d))); + const url = 'magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&dn=Big+Buck+Bunny&tr=udp%3A%2F%2Ft.example%3A80'; + d.add(url); + await tester.pump(); + expect(d.tasks.single.state, DownloadState.downloading); + expect(find.text('Big Buck Bunny'), findsOneWidget, reason: '마그넷 주소 전체가 아니라 이름'); + expect(find.textContaining('urn:btih'), findsNothing); + // 메타데이터를 받는 중: 움직이는 "준비 중…" + LinearProgressIndicator bar() => tester.widget(find.descendant(of: find.byType(DownloadProgressBar), matching: find.byType(LinearProgressIndicator))); + expect(find.text('준비 중…'), findsOneWidget); + expect(bar().value, isNull); + // 정한 시간 동안 트래커 · 피어가 없음 → 멈춤 + d.tasks.single.extra + ..['stalled'] = true + ..['stallSince'] = DateTime.now().millisecondsSinceEpoch - 61000 + ..['stallReasons'] = ['firewall']; + d.selectNone(); + await tester.pump(); + expect(find.textContaining('트래커 · 피어에 닿지 않습니다'), findsOneWidget); + expect(find.text('멈춤'), findsOneWidget); + expect(find.text('준비 중…'), findsNothing); + expect(bar().value, 0, reason: '움직이지 않는 막대'); + expect(bar().color, Colors.orangeAccent.withValues(alpha: 0.85)); + await tester.pumpAndSettle(); // 움직이는 막대가 있으면 끝나지 않는다 + // 다시 받기 시작하면 (멈춤 풀림) 원래대로 + d.tasks.single.extra.remove('stalled'); + d.selectNone(); + await tester.pump(); + expect(find.text('준비 중…'), findsOneWidget); + expect(bar().value, isNull); + }); }