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) => '$body'; 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 = '집 NAS' 'urn:schemas-upnp-org:service:ConnectionManager:1/cm' 'urn:schemas-upnp-org:service:ContentDirectory:1ctl/ContentDir' ''; 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('', 'http://192.168.0.9:9000/'), 'http://192.168.0.5:8200/rootDesc.xml')!; expect(withBase.controlUrl, 'http://192.168.0.9:9000/ctl/ContentDir'); expect(parseDescription('x', 'http://1.2.3.4/'), isNull); }); test('DIDL: 폴더 · 동영상 · 음악 · 사진 · 크기 · 길이, 같은 망 밖의 주소는 열지 않음', () { final list = parseDidl(didl( '영화object.container' '1화.mkvobject.item.videoItem' 'http://192.168.0.5:8200/MediaItems/1.mkv' '노래object.item.audioItem.musicTrack' 'http://10.0.0.2/s.mp3' '밖object.item.imageItem.photo' 'http://8.8.8.8/x.jpg')); 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 = []; 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'(\d+)').firstMatch(body)?[1] ?? '?'; starts.add('${req.headers.value('soapaction')}|$start'); final result = didl('사진 $startobject.item.imageItem' 'http://127.0.0.1:${srv.port}/p$start.jpg'); final esc = result.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>'); req.response.headers.contentType = ContentType('text', 'xml', charset: 'utf-8'); req.response.write('' '$esc' '12'); 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())); }, _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: [], 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 {}