next8: + magnet_ui (197ba04: dn 이름 제목 · 멈춘 줄 막대) - 시험판 980

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
next8
RyuJongJin 2026-10-20 12:24:16 +09:00
commit 2033c48e5e
5 changed files with 192 additions and 4 deletions

View File

@ -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<PlaylistEntry>)?> expandPlaylist(String url) async => null;
@override
Future<void> start(DownloadTask t, void Function() changed) async {
t
..state = DownloadState.downloading
..progress = null;
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 {}
}
/// 관리자 지시 (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<void>.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());
});
}
}
}

View File

@ -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<DetectedLink> detectLinks(String text) {
final found = <int, DetectedLink>{};

View File

@ -29,7 +29,7 @@ class DownloadTask {
final Map<String, Object?> 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;

View File

@ -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(

View File

@ -30,6 +30,18 @@ class _Backend implements DownloadBackend {
Future<void> shutdown() async {}
}
/// 이름을 바꾸지 않는 백엔드 (마그넷 메타데이터를 아직 받지 못한 상태)
class _Quiet extends _Backend {
_Quiet(super.kind);
@override
Future<void> 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);
});
}