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/lan_scan.dart' show activeMeteredOf; 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/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.explorerRemoteThumbs, s.explorerRemoteThumbCacheMb), ('wifi', 200)); expect(AppSettings.fromJson({'explorerRemoteThumbs': 'x', 'explorerRemoteThumbCacheMb': 'y'}).explorerRemoteThumbs, 'wifi'); final back = AppSettings.fromJson((AppSettings() ..explorerRemoteThumbs = 'off' ..explorerRemoteThumbCacheMb = 500) .toJson()); expect((back.explorerRemoteThumbs, back.explorerRemoteThumbCacheMb), ('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)', () { final was = activeMeteredOf; tearDown(() { activeMeteredOf = was; resetRemoteThumbNetForTest(); }); AppController ctl() => AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); test('Wi-Fi 에서만 (처음 값): 데이터 요금제 망이면 아니오 · 아니면 예 · 끔 · 늘', () async { final c = ctl(); activeMeteredOf = () async => true; resetRemoteThumbNetForTest(); expect(await remoteThumbsAllowedNow(c), isFalse, reason: '모바일 데이터 (VPN 이어도 그 아래가 모바일 데이터)'); activeMeteredOf = () async => false; resetRemoteThumbNetForTest(); expect(await remoteThumbsAllowedNow(c), isTrue, reason: 'Wi-Fi (집 Wi-Fi 위의 Tailscale 포함)'); c.settings.explorerRemoteThumbs = 'off'; expect(await remoteThumbsAllowedNow(c), isFalse); c.settings.explorerRemoteThumbs = 'always'; activeMeteredOf = () async => true; resetRemoteThumbNetForTest(); 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('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.explorerRemoteThumbs = 'always'; resetRemoteThumbsForTest(); addTearDown(resetRemoteThumbsForTest); 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'); }); }