import 'dart:async'; import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:jj_mkvmaker/app/app_controller.dart'; import 'package:jj_mkvmaker/app/settings.dart'; import 'package:jj_mkvmaker/core/file_ops.dart' show FileEntry; import 'package:jj_mkvmaker/core/models.dart'; import 'package:jj_mkvmaker/core/remote_stream.dart'; import 'package:jj_mkvmaker/core/remote_thumbs.dart'; import 'package:jj_mkvmaker/core/vfs.dart'; import 'package:jj_mkvmaker/core/webdav.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/media_tool.dart'; import 'package:jj_mkvmaker/services/platform_services.dart'; import 'package:jj_mkvmaker/ui/remote_thumb_view.dart'; import 'package:path/path.dart' as p; import 'support/dav_server.dart'; /// X-plore 비교 5-1 ② (관리자 지시 10/21): 원격 썸네일 - 처음 값 Wi-Fi 에서만 · 동시 2개 · 보이는 줄만 · 캐시 상한 · [캐시 지우기] void main() { late Directory tmp; setUp(() => tmp = Directory.systemTemp.createTempSync('jj_rthumb_')); tearDown(() => tmp.deleteSync(recursive: true)); RemoteThumbs hub({ int maxBytes = 1 << 30, bool allowed = true, Future Function(String path, String out)? make, }) => RemoteThumbs( dir: Directory(p.join(tmp.path, 'cache')), maxBytes: () => maxBytes, allowed: () async => allowed, make: make ?? (path, out) async { await File(out).writeAsBytes(List.filled(100, 1)); return true; }, ); test('설정: 처음 값 Wi-Fi 에서만 · 캐시 200MB, 틀린 값은 처음 값으로', () { final s = AppSettings(); expect((s.explorer.remoteThumbs, s.explorer.remoteThumbCacheMb), ('wifi', 200)); expect(AppSettings.fromJson({'explorerRemoteThumbs': 'x', 'explorerRemoteThumbCacheMb': 'y'}).explorer.remoteThumbs, 'wifi'); final back = AppSettings.fromJson((AppSettings() ..explorer.remoteThumbs = 'off' ..explorer.remoteThumbCacheMb = 500) .toJson()); expect((back.explorer.remoteThumbs, back.explorer.remoteThumbCacheMb), ('off', 500)); }); /// 파일 읽기 · 쓰기 (실제 IO) 를 기다린다 - [ok] 가 될 때까지 (5초까지) Future until(bool Function() ok) async { for (var i = 0; i < 250 && !ok(); i++) { await Future.delayed(const Duration(milliseconds: 20)); } } test('동시에 2개까지 · 화면에서 사라진 줄은 시작하지 않음 · 같은 것은 한 번만', () async { final gates = >{}; final h = hub(make: (path, out) async { await (gates[path] = Completer()).future; await File(out).writeAsBytes([1, 2, 3]); return true; }); var gone = false; final a = h.request('dav://n/a.jpg', 'a', wanted: () => true); final a2 = h.request('dav://n/a.jpg', 'a', wanted: () => true); final b = h.request('dav://n/b.jpg', 'b', wanted: () => true); final cc = h.request('dav://n/c.jpg', 'c', wanted: () => !gone); final d = h.request('dav://n/d.jpg', 'd', wanted: () => true); await until(() => h.made.length >= 2); await Future.delayed(const Duration(milliseconds: 100)); expect(h.made.toSet(), {'dav://n/a.jpg', 'dav://n/b.jpg'}, reason: '2개만 시작'); gone = true; // c 줄이 화면에서 사라짐 gates['dav://n/a.jpg']!.complete(); await until(() => h.made.length >= 3); expect(h.made.toSet(), {'dav://n/a.jpg', 'dav://n/b.jpg', 'dav://n/d.jpg'}, reason: 'c 는 건너뛰고 d'); gates['dav://n/b.jpg']!.complete(); gates['dav://n/d.jpg']!.complete(); expect(await a, isNotNull); expect(await a2, isNotNull); expect((await a)!.path, (await a2)!.path, reason: '같은 것은 한 번만 받음'); expect(await b, isNotNull); expect(await cc, isNull); expect(await d, isNotNull); expect(h.peak, 2); // 다시 보이면 다시 달라고 해서 받는다 final again = h.request('dav://n/c.jpg', 'c', wanted: () => true); await until(() => gates.containsKey('dav://n/c.jpg')); gates['dav://n/c.jpg']!.complete(); expect(await again, isNotNull); }); test('받지 않는 망 (allowed false) 이면 만들지 않음 · 디스크에 있는 것은 다시 씀', () async { final off = hub(allowed: false); expect(await off.request('dav://n/a.jpg', 'a', wanted: () => true), isNull); expect(off.made, isEmpty); final on = hub(); final f = await on.request('dav://n/a.jpg', 'a', wanted: () => true); expect(f, isNotNull); // 앱을 다시 켠 것처럼 새 hub - 디스크의 것을 쓰고 받지 않는다 (망이 막혀 있어도) final again = hub(allowed: false); expect((await again.request('dav://n/a.jpg', 'a', wanted: () => true))?.path, f!.path); expect(again.made, isEmpty); }); test('캐시 상한을 넘으면 오래 쓰지 않은 것부터 지움 · [캐시 지우기]', () async { final h = hub(maxBytes: 250); // 100 바이트짜리 3개면 넘음 for (final (i, n) in ['a', 'b', 'c'].indexed) { final f = await h.request('dav://n/$n.jpg', n, wanted: () => true); await f!.setLastModified(DateTime(2026, 1, 1 + i)); } final d = await h.request('dav://n/d.jpg', 'd', wanted: () => true); expect(d, isNotNull); expect(await h.size(), lessThanOrEqualTo(250)); expect(h.cached('a'), isNull, reason: '가장 옛것 (a) 이 지워짐'); expect(h.cached('d'), isNotNull); await h.clear(); expect(await h.size(), 0); expect(h.cached('d'), isNull); }); group('받아도 되는 망 (remoteThumbsAllowedNow)', () { // 설계 M3 ThumbPolicy: 망 판단은 PlatformServices.isMetered 로 넘긴다 (예전 전역 함수 activeMeteredOf) var metered = true; tearDown(forgetRemoteThumbNet); AppController ctl() => AppController( PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService(), isMetered: () async => metered)); test('Wi-Fi 에서만 (처음 값): 데이터 요금제 망이면 아니오 · 아니면 예 · 끔 · 늘', () async { final c = ctl(); metered = true; forgetRemoteThumbNet(); expect(await remoteThumbsAllowedNow(c), isFalse, reason: '모바일 데이터 (VPN 이어도 그 아래가 모바일 데이터)'); metered = false; forgetRemoteThumbNet(); expect(await remoteThumbsAllowedNow(c), isTrue, reason: 'Wi-Fi (집 Wi-Fi 위의 Tailscale 포함)'); c.settings.explorer.remoteThumbs = 'off'; expect(await remoteThumbsAllowedNow(c), isFalse); c.settings.explorer.remoteThumbs = 'always'; metered = true; forgetRemoteThumbNet(); expect(await remoteThumbsAllowedNow(c), isTrue); }); }); test('받을 종류: 그림 (30MB 아래) · 동영상만', () { FileEntry f(String n, int size) => FileEntry('dav://n/$n', isDir: false, size: size, modified: DateTime(2026)); expect(remoteThumbKind(f('a.jpg', 1000)), isTrue); expect(remoteThumbKind(f('a.jpg', 40 * 1024 * 1024)), isFalse); expect(remoteThumbKind(f('a.mp4', 4 << 30)), isTrue, reason: '동영상은 일부만 읽음'); expect(remoteThumbKind(f('a.txt', 10)), isFalse); }); group('WebDAV Range (스트리밍 다리 · 썸네일이 일부만 읽게)', () { late TestDavServer server; setUp(() async { final root = Directory(p.join(tmp.path, 'remote'))..createSync(); File(p.join(root.path, 'v.bin')).writeAsBytesSync(List.generate(1000, (i) => i % 256)); server = TestDavServer(root); await server.start(); DavRegistry.configure([DavServer(id: 'nas', name: 'x', url: server.url, user: 'user', password: 'pass')]); }); tearDown(() async { DavRegistry.configure([]); await RemoteStreamBridge.close(); await server.stop(); }); Future> read(int start, int? length) async => (await (await RemoteStreamBridge.openRange('dav://nas/v.bin', start, length)).toList()).expand((x) => x).toList(); test('Range 를 아는 서버는 그 부분만 받음 (206) · 모르는 서버는 받으며 건너뜀 - 결과는 같음', () async { final want = [for (var i = 900; i < 950; i++) i % 256]; server.ranges = true; expect(await read(900, 50), want); expect(server.rangeHeaders.last, 'bytes=900-949'); expect(await read(990, null), [for (var i = 990; i < 1000; i++) i % 256]); expect(server.rangeHeaders.last, 'bytes=990-'); server.ranges = false; expect(await read(900, 50), want, reason: 'Range 를 모르면 (200) 처음부터 받으며 건너뜀'); }); test('28 지적 (10/21): Range 를 모르는 서버 (200) 의 동영상은 썸네일을 받지 않음 · 서버마다 기억 · 206 서버는 만듦 · 시스템 폴더는 안 함', () async { forgetRemoteRangeSupport(); addTearDown(forgetRemoteRangeSupport); server.ranges = false; for (final n in ['v.mp4', 'w.mp4']) { File(p.join(tmp.path, 'remote', n)).writeAsBytesSync(List.filled(1000, 7)); } expect(await (DavPath.parse('dav://nas/v.bin').client as DavClient).rangeSupported('/v.bin'), isFalse); final calls = >[]; final c = AppController(PlatformServices(mediaTool: _Ffmpeg(calls), storage: DesktopStorageService())); c.settings.explorer.remoteThumbs = 'always'; forgetRemoteThumbHub(); addTearDown(forgetRemoteThumbHub); final h = await remoteThumbsOf(c); final probes = server.rangeHeaders.length; expect(await h.request('dav://nas/v.mp4', 'k1', wanted: () => true), isNull); expect(calls, isEmpty, reason: 'FFmpeg 를 부르지 않음 (파일을 받지 않음)'); expect(server.rangeHeaders.length, probes + 1, reason: '한 바이트만 물어봄'); expect(await h.request('dav://nas/w.mp4', 'k2', wanted: () => true), isNull); expect(server.rangeHeaders.length, probes + 1, reason: '같은 서버는 다시 묻지 않음'); expect(await remoteVideoThumbOk('shz://sys/storage/emulated/0/a.mp4'), isFalse, reason: '시스템 폴더는 늘 통째 복사'); expect(await remoteVideoThumbOk('sftp://s/a.mp4'), isTrue); // 206 서버 (앱을 다시 켠 것처럼 기억을 지우고) forgetRemoteRangeSupport(); server.ranges = true; expect(await remoteVideoThumbOk('dav://nas/v.bin'), isTrue); expect(await h.request('dav://nas/v.mp4', 'k3', wanted: () => true), isNull, reason: '가짜 FFmpeg 는 그림을 만들지 않음'); expect(calls, isNotEmpty, reason: '206 서버는 지금처럼 다리로 만든다'); await h.clear(); }); test('썸네일 하나에 보내는 양의 상한 (넘으면 끊음) · 플레이어 주소는 상한 없음', () async { server.ranges = true; final b = await RemoteStreamBridge.get(); final capped = b.url('dav://nas/v.bin', cap: 100); final http = HttpClient(); addTearDown(() => http.close(force: true)); Future fetch(String url) async { var n = 0; try { final res = await (await http.getUrl(Uri.parse(url))).close(); await for (final x in res) { n += x.length; } } catch (_) {} return n; } expect(await fetch(capped), lessThanOrEqualTo(100)); expect(b.sentOf(capped), 100); expect(await fetch(capped), 0, reason: '같은 썸네일 주소로 다시 와도 상한을 넘지 않음'); expect(await fetch(b.url('dav://nas/v.bin')), 1000, reason: '플레이어 주소는 그대로'); }); test('FFmpeg 가 다리에서 원격 동영상 썸네일을 만든다 (비밀번호는 명령 줄에 없음)', () async { final tool = ProcessMediaTool('ffmpeg', 'ffprobe'); if (await tool.version() == null) return markTestSkipped('FFmpeg 없음'); final mp4 = p.join(tmp.path, 'remote', 'clip.mp4'); await tool.runFfmpeg(['-y', '-f', 'lavfi', '-i', 'testsrc=size=320x240:rate=10', '-t', '7', '-pix_fmt', 'yuv420p', mp4]); server.ranges = true; final c = AppController(PlatformServices(mediaTool: tool, storage: DesktopStorageService())); c.settings.explorer.remoteThumbs = 'always'; forgetRemoteThumbHub(); addTearDown(forgetRemoteThumbHub); final h = await remoteThumbsOf(c); final st = File(mp4).statSync(); final f = await h.request('dav://nas/clip.mp4', RemoteThumbs.keyOf('dav://nas/clip.mp4', st.size, st.modified), wanted: () => true); expect(f, isNotNull); expect(f!.lengthSync(), greaterThan(100)); expect(server.rangeHeaders, isNotEmpty, reason: '다리를 거쳐 Range 로 읽음'); await h.clear(); }); }); test('skipBytes: 조각 경계를 넘어 건너뛰고 자름', () async { final s = Stream.fromIterable([ [0, 1, 2], [3, 4], [5, 6, 7, 8], ]); expect((await skipBytes(s, 2, 4).toList()).expand((x) => x).toList(), [2, 3, 4, 5]); expect(vBasename('dav://n/a/b.jpg'), 'b.jpg'); }); } /// FFmpeg 를 부른 것만 적는다 (그림은 만들지 않음) class _Ffmpeg implements MediaTool { final List> calls; _Ffmpeg(this.calls); @override Future version() async => 'ffmpeg test'; @override Future> encoders() async => {}; @override Future probe(String path) async => const MediaInfo(duration: Duration.zero, streams: []); @override Future runFfmpeg(List args, {Duration? duration, ProgressCallback? onProgress, Object? tag}) async => calls.add(args); @override void cancel() {} @override void cancelTagged(Object tag) {} }