143 lines
8.9 KiB
Dart
143 lines
8.9 KiB
Dart
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:jj_mkvmaker/app/app_controller.dart';
|
|
import 'package:jj_mkvmaker/core/dlna.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/platform_services.dart';
|
|
import 'package:jj_mkvmaker/ui/dlna_page.dart';
|
|
|
|
/// X-plore 비교 5-2 (사용자 결정 10/21 "DLNA 보기만"): 찾기 답 · 장치 설명 · DIDL 읽기, 같은 망 밖 주소는 따르지 않음, 화면
|
|
void main() {
|
|
String didl(String body) => '<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" '
|
|
'xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/">$body</DIDL-Lite>';
|
|
|
|
test('SSDP 답: 미디어 서버의 LOCATION 만 (머리 이름 대소문자 무시)', () {
|
|
expect(parseSsdpLocation('HTTP/1.1 200 OK\r\nCACHE-CONTROL: max-age=1800\r\nlocation: http://192.168.0.5:8200/rootDesc.xml\r\n'
|
|
'ST: urn:schemas-upnp-org:device:MediaServer:1\r\n\r\n'), 'http://192.168.0.5:8200/rootDesc.xml');
|
|
expect(parseSsdpLocation('HTTP/1.1 200 OK\r\nLOCATION: http://x/\r\nST: urn:schemas-upnp-org:device:InternetGatewayDevice:1\r\n\r\n'), isNull);
|
|
expect(parseSsdpLocation('NOTIFY * HTTP/1.1\r\nLOCATION: http://x/\r\n\r\n'), isNull);
|
|
expect(ssdpSearchMessage(), contains('ST: urn:schemas-upnp-org:device:MediaServer:1'));
|
|
});
|
|
|
|
test('장치 설명: 이름 · ContentDirectory 제어 주소 (상대 주소는 URLBase · LOCATION 기준)', () {
|
|
const xml = '<root xmlns="urn:schemas-upnp-org:device-1-0"><device><friendlyName>집 NAS</friendlyName><serviceList>'
|
|
'<service><serviceType>urn:schemas-upnp-org:service:ConnectionManager:1</serviceType><controlURL>/cm</controlURL></service>'
|
|
'<service><serviceType>urn:schemas-upnp-org:service:ContentDirectory:1</serviceType><controlURL>ctl/ContentDir</controlURL></service>'
|
|
'</serviceList></device></root>';
|
|
final s = parseDescription(xml, 'http://192.168.0.5:8200/rootDesc.xml')!;
|
|
expect((s.name, s.controlUrl), ('집 NAS', 'http://192.168.0.5:8200/ctl/ContentDir'));
|
|
final withBase = parseDescription(xml.replaceFirst('<device>', '<URLBase>http://192.168.0.9:9000/</URLBase><device>'),
|
|
'http://192.168.0.5:8200/rootDesc.xml')!;
|
|
expect(withBase.controlUrl, 'http://192.168.0.9:9000/ctl/ContentDir');
|
|
expect(parseDescription('<root><device><friendlyName>x</friendlyName></device></root>', 'http://1.2.3.4/'), isNull);
|
|
});
|
|
|
|
test('DIDL: 폴더 · 동영상 · 음악 · 사진 · 크기 · 길이, 같은 망 밖의 주소는 열지 않음', () {
|
|
final list = parseDidl(didl(
|
|
'<container id="64" parentID="0" childCount="12"><dc:title>영화</dc:title><upnp:class>object.container</upnp:class></container>'
|
|
'<item id="64\$1"><dc:title>1화.mkv</dc:title><upnp:class>object.item.videoItem</upnp:class>'
|
|
'<res protocolInfo="http-get:*:video/x-matroska:*" size="123456" duration="0:45:01.000">http://192.168.0.5:8200/MediaItems/1.mkv</res></item>'
|
|
'<item id="a"><dc:title>노래</dc:title><upnp:class>object.item.audioItem.musicTrack</upnp:class>'
|
|
'<res protocolInfo="http-get:*:audio/mpeg:*">http://10.0.0.2/s.mp3</res></item>'
|
|
'<item id="b"><dc:title>밖</dc:title><upnp:class>object.item.imageItem.photo</upnp:class>'
|
|
'<res protocolInfo="http-get:*:image/jpeg:*">http://8.8.8.8/x.jpg</res></item>'));
|
|
expect(list.map((e) => (e.title, e.isContainer, e.kind)), [('영화', true, 'other'), ('1화.mkv', false, 'video'), ('노래', false, 'audio'), ('밖', false, 'image')]);
|
|
expect(list[0].childCount, 12);
|
|
expect((list[1].size, list[1].duration, list[1].mime), (123456, const Duration(minutes: 45, seconds: 1), 'video/x-matroska'));
|
|
expect(list[1].url, 'http://192.168.0.5:8200/MediaItems/1.mkv');
|
|
expect(list[3].url, isNull, reason: '바깥 주소 (8.8.8.8) 는 따르지 않음');
|
|
expect(dlnaHostAllowed('192.168.1.10'), isTrue);
|
|
expect(dlnaHostAllowed('127.0.0.1'), isTrue);
|
|
expect(dlnaHostAllowed('100.80.1.2'), isFalse, reason: 'Tailscale 범위 (CGNAT) 는 사설 LAN 이 아님');
|
|
expect(dlnaHostAllowed('example.com'), isFalse);
|
|
});
|
|
|
|
test('보낼 주소가 없으면 (Wi-Fi · 유선 아님) 찾지 않고 빈 목록', () async {
|
|
expect(await discoverDlna(addresses: const [], wait: Duration.zero), isEmpty);
|
|
});
|
|
|
|
test('가짜 서버: Browse (SOAP) 로 목록 · 전체 수 · 이어 읽기 (StartingIndex)', () async {
|
|
final starts = <String>[];
|
|
final srv = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
|
addTearDown(() => srv.close(force: true));
|
|
srv.listen((req) async {
|
|
final body = await utf8.decoder.bind(req).join();
|
|
final start = RegExp(r'<StartingIndex>(\d+)</StartingIndex>').firstMatch(body)?[1] ?? '?';
|
|
starts.add('${req.headers.value('soapaction')}|$start');
|
|
final result = didl('<item id="i$start"><dc:title>사진 $start</dc:title><upnp:class>object.item.imageItem</upnp:class>'
|
|
'<res protocolInfo="http-get:*:image/jpeg:*">http://127.0.0.1:${srv.port}/p$start.jpg</res></item>');
|
|
final esc = result.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>');
|
|
req.response.headers.contentType = ContentType('text', 'xml', charset: 'utf-8');
|
|
req.response.write('<?xml version="1.0"?><s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"><s:Body>'
|
|
'<u:BrowseResponse xmlns:u="urn:schemas-upnp-org:service:ContentDirectory:1"><Result>$esc</Result>'
|
|
'<NumberReturned>1</NumberReturned><TotalMatches>2</TotalMatches></u:BrowseResponse></s:Body></s:Envelope>');
|
|
await req.response.close();
|
|
});
|
|
final s = DlnaServer('가짜', 'http://127.0.0.1:${srv.port}/desc.xml', 'http://127.0.0.1:${srv.port}/ctl');
|
|
// 위젯 시험이 있는 파일은 시험 binding 이 HttpClient 를 가짜로 바꾼다 - 진짜 연결로
|
|
await HttpOverrides.runWithHttpOverrides(() async {
|
|
final a = await dlnaBrowse(s, objectId: '1&2');
|
|
expect(a.total, 2);
|
|
expect(a.entries.single.title, '사진 0');
|
|
expect(a.entries.single.url, 'http://127.0.0.1:${srv.port}/p0.jpg');
|
|
final b = await dlnaBrowse(s, objectId: '1&2', start: 1);
|
|
expect(b.entries.single.title, '사진 1');
|
|
expect(starts, ['"urn:schemas-upnp-org:service:ContentDirectory:1#Browse"|0', '"urn:schemas-upnp-org:service:ContentDirectory:1#Browse"|1']);
|
|
await expectLater(dlnaBrowse(const DlnaServer('밖', 'http://8.8.8.8/', 'http://8.8.8.8/ctl')), throwsA(isA<DlnaException>()));
|
|
}, _RealHttp());
|
|
});
|
|
|
|
testWidgets('화면: 서버 목록 → 폴더 → 안의 폴더 → ← 로 위 → 서버 목록 · 바깥 주소 항목은 "열 수 없음" · 못 찾으면 안내', (tester) async {
|
|
tester.view.physicalSize = const Size(900, 1200);
|
|
tester.view.devicePixelRatio = 1;
|
|
addTearDown(tester.view.reset);
|
|
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
|
const nas = DlnaServer('집 NAS', 'http://192.168.0.5/d.xml', 'http://192.168.0.5/ctl');
|
|
await tester.pumpWidget(MaterialApp(
|
|
home: DlnaPage(
|
|
c: c,
|
|
discover: () async => [nas],
|
|
browse: (s, id, start) async => switch (id) {
|
|
'0' => (entries: [const DlnaEntry('v', '동영상', isContainer: true, childCount: 2)], total: 1),
|
|
'v' => (
|
|
entries: [
|
|
const DlnaEntry('v1', '안쪽', isContainer: true),
|
|
const DlnaEntry('x', '밖.mp4', upnpClass: 'object.item.videoItem'),
|
|
],
|
|
total: 2
|
|
),
|
|
_ => (entries: <DlnaEntry>[], total: 0),
|
|
},
|
|
),
|
|
));
|
|
await tester.pumpAndSettle();
|
|
expect(find.textContaining('보기만 합니다'), findsOneWidget);
|
|
await tester.tap(find.text('집 NAS'));
|
|
await tester.pumpAndSettle();
|
|
expect(find.text('동영상'), findsOneWidget);
|
|
await tester.tap(find.text('동영상'));
|
|
await tester.pumpAndSettle();
|
|
expect(find.text('안쪽'), findsOneWidget);
|
|
expect(find.text('열 수 없음'), findsOneWidget);
|
|
await tester.tap(find.text('밖.mp4'));
|
|
await tester.pump();
|
|
expect(find.text('이 항목은 같은 망 밖의 주소라 열지 않습니다'), findsOneWidget);
|
|
await tester.tap(find.byTooltip('뒤로'));
|
|
await tester.pumpAndSettle();
|
|
expect(find.text('동영상'), findsOneWidget);
|
|
await tester.tap(find.byTooltip('뒤로'));
|
|
await tester.pumpAndSettle();
|
|
expect(find.text('집 NAS'), findsOneWidget);
|
|
|
|
await tester.pumpWidget(MaterialApp(home: DlnaPage(key: UniqueKey(), c: c, discover: () async => const [])));
|
|
await tester.pumpAndSettle();
|
|
expect(find.textContaining('미디어 서버를 찾지 못했습니다'), findsOneWidget);
|
|
});
|
|
}
|
|
|
|
class _RealHttp extends HttpOverrides {}
|