next8: + magnet_ui (197ba04: dn 이름 제목 · 멈춘 줄 막대) - 시험판 980
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>next8
commit
2033c48e5e
|
|
@ -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 _magnet = RegExp(r'magnet:\?xt=urn:bt[im]h:[^\s<>"]+', caseSensitive: false);
|
||||||
final _torrentUrl = RegExp(r'https?://[^\s<>"]+\.torrent(?:\?[^\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) {
|
List<DetectedLink> detectLinks(String text) {
|
||||||
final found = <int, DetectedLink>{};
|
final found = <int, DetectedLink>{};
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ class DownloadTask {
|
||||||
final Map<String, Object?> extra = {};
|
final Map<String, Object?> extra = {};
|
||||||
|
|
||||||
DownloadTask({required this.id, required this.kind, required this.source, required this.dir})
|
DownloadTask({required this.id, required this.kind, required this.source, required this.dir})
|
||||||
: title = source;
|
: title = magnetName(source) ?? source; // 마그넷은 메타데이터 전에도 dn 이름으로 (받으면 토렌트 이름으로 바뀜)
|
||||||
|
|
||||||
bool get unfinished =>
|
bool get unfinished =>
|
||||||
state == DownloadState.queued || state == DownloadState.downloading || state == DownloadState.paused;
|
state == DownloadState.queued || state == DownloadState.downloading || state == DownloadState.paused;
|
||||||
|
|
|
||||||
|
|
@ -400,7 +400,11 @@ class DownloadProgressBar extends StatelessWidget {
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final done = t.state == DownloadState.done;
|
final done = t.state == DownloadState.done;
|
||||||
final value = done ? 1.0 : t.progress;
|
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) {
|
final color = switch (t.state) {
|
||||||
|
_ when stalled => Colors.orangeAccent,
|
||||||
DownloadState.done => JjColors.success,
|
DownloadState.done => JjColors.success,
|
||||||
DownloadState.failed => JjColors.danger,
|
DownloadState.failed => JjColors.danger,
|
||||||
DownloadState.paused => Colors.amber,
|
DownloadState.paused => Colors.amber,
|
||||||
|
|
@ -414,7 +418,9 @@ class DownloadProgressBar extends StatelessWidget {
|
||||||
? tr('실패') // 136: 실패한 것에 "준비 중…" 이 남지 않게 (작은 다운로드 상자 등)
|
? tr('실패') // 136: 실패한 것에 "준비 중…" 이 남지 않게 (작은 다운로드 상자 등)
|
||||||
: t.state == DownloadState.queued || t.extra['waiting'] == true
|
: t.state == DownloadState.queued || t.extra['waiting'] == true
|
||||||
? tr('대기 중')
|
? tr('대기 중')
|
||||||
: tr('준비 중…'))
|
: stalled
|
||||||
|
? tr('멈춤')
|
||||||
|
: tr('준비 중…'))
|
||||||
: '${(value * 100).toStringAsFixed(1)}%${size.isEmpty ? '' : ' · $size'}';
|
: '${(value * 100).toStringAsFixed(1)}%${size.isEmpty ? '' : ' · $size'}';
|
||||||
return ClipRRect(
|
return ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
|
@ -423,9 +429,10 @@ class DownloadProgressBar extends StatelessWidget {
|
||||||
child: Stack(fit: StackFit.expand, children: [
|
child: Stack(fit: StackFit.expand, children: [
|
||||||
LinearProgressIndicator(
|
LinearProgressIndicator(
|
||||||
// 진행률을 모를 때(받기 준비 · 마그넷 정보 받는 중)는 움직이는 막대
|
// 진행률을 모를 때(받기 준비 · 마그넷 정보 받는 중)는 움직이는 막대
|
||||||
value: value ?? (t.state == DownloadState.downloading ? null : 0),
|
value: value ?? (t.state == DownloadState.downloading && !stalled ? null : 0),
|
||||||
minHeight: height,
|
minHeight: height,
|
||||||
backgroundColor: JjColors.panelHigh,
|
// 멈춘 줄은 빈 막대도 주황 기운으로 (회색 빈 막대면 그냥 시작 전처럼 보임)
|
||||||
|
backgroundColor: stalled ? Colors.orangeAccent.withValues(alpha: 0.28) : JjColors.panelHigh,
|
||||||
color: color.withValues(alpha: 0.85),
|
color: color.withValues(alpha: 0.85),
|
||||||
),
|
),
|
||||||
Center(
|
Center(
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,18 @@ class _Backend implements DownloadBackend {
|
||||||
Future<void> shutdown() async {}
|
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() {
|
void main() {
|
||||||
DownloadManager make() => DownloadManager(
|
DownloadManager make() => DownloadManager(
|
||||||
backends: [_Backend(DownloadKind.video), _Backend(DownloadKind.torrent)],
|
backends: [_Backend(DownloadKind.video), _Backend(DownloadKind.torrent)],
|
||||||
|
|
@ -178,4 +190,61 @@ void main() {
|
||||||
expect(find.textContaining('대기 중'), findsWidgets);
|
expect(find.textContaining('대기 중'), findsWidgets);
|
||||||
expect(find.textContaining('받는 중'), findsNothing);
|
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