마그넷 줄: 메타데이터 전에도 dn 이름 · 멈춘 줄은 막대가 움직이지 않고 주황 "멈춤" (검토 대기)
관리자 지시 (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 <noreply@anthropic.com>
magnet_ui
parent
6b60f348e2
commit
197ba047c4
|
|
@ -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());
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>{};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue