JJ_MKVMaker/test/remote_thumbs_test.dart

291 lines
14 KiB
Dart

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/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<bool> 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<void> until(bool Function() ok) async {
for (var i = 0; i < 250 && !ok(); i++) {
await Future<void>.delayed(const Duration(milliseconds: 20));
}
}
test('동시에 2개까지 · 화면에서 사라진 줄은 시작하지 않음 · 같은 것은 한 번만', () async {
final gates = <String, Completer<void>>{};
final h = hub(make: (path, out) async {
await (gates[path] = Completer<void>()).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<void>.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;
forgetRemoteThumbNet();
});
AppController ctl() => AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
test('Wi-Fi 에서만 (처음 값): 데이터 요금제 망이면 아니오 · 아니면 예 · 끔 · 늘', () async {
final c = ctl();
activeMeteredOf = () async => true;
forgetRemoteThumbNet();
expect(await remoteThumbsAllowedNow(c), isFalse, reason: '모바일 데이터 (VPN 이어도 그 아래가 모바일 데이터)');
activeMeteredOf = () async => 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';
activeMeteredOf = () async => 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<List<int>> 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 = <List<String>>[];
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<int> 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<List<String>> calls;
_Ffmpeg(this.calls);
@override
Future<String?> version() async => 'ffmpeg test';
@override
Future<Set<String>> encoders() async => {};
@override
Future<MediaInfo> probe(String path) async => const MediaInfo(duration: Duration.zero, streams: []);
@override
Future<void> runFfmpeg(List<String> args, {Duration? duration, ProgressCallback? onProgress, Object? tag}) async => calls.add(args);
@override
void cancel() {}
@override
void cancelTagged(Object tag) {}
}