217 lines
10 KiB
Dart
217 lines
10 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/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<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.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<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;
|
|
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<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('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');
|
|
});
|
|
}
|