JJ_MKVMaker/test/reader_test.dart

302 lines
15 KiB
Dart

import 'dart:io';
import 'package:archive/archive_io.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:image/image.dart' as img;
import 'package:jj_mkvmaker/app/app_controller.dart';
import 'package:jj_mkvmaker/app/settings.dart';
import 'package:jj_mkvmaker/core/reader_sources.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/reader_page.dart';
import 'package:path/path.dart' as p;
Uint8List _png(int r, int g, int b, {int w = 40, int h = 60}) {
final im = img.Image(width: w, height: h);
img.fill(im, color: img.ColorRgb8(r, g, b));
return Uint8List.fromList(img.encodePng(im));
}
void main() {
late Directory tmp;
setUp(() => tmp = Directory.systemTemp.createTempSync('jj_reader_'));
tearDown(() => tmp.deleteSync(recursive: true));
test('ZIP: 목록 · 그림만 자연 순서 · 골라서 / 모두 풀기 (바깥 경로 막기)', () async {
final zip = p.join(tmp.path, 'comic.zip');
final a = Archive()
..add(ArchiveFile.bytes('ch1/10.png', _png(1, 2, 3)))
..add(ArchiveFile.bytes('ch1/2.png', _png(4, 5, 6)))
..add(ArchiveFile.bytes('ch1/1.jpg', img.encodeJpg(img.Image(width: 8, height: 8))))
..add(ArchiveFile.bytes('readme.txt', 'hi'.codeUnits))
..add(ArchiveFile.bytes('../evil.txt', 'x'.codeUnits));
File(zip).writeAsBytesSync(ZipEncoder().encode(a));
final entries = await zipEntries(zip, tempDir: tmp.path);
expect(entries.where((f) => f.isFile).length, 5);
final src = await ZipImagesSource.open(zip, tempDir: tmp.path, imageExts: defaultImageExtensions);
expect([for (var i = 0; i < src.length; i++) src.pageName(i)], ['1.jpg', '2.png', '10.png']);
final page = await src.load(1, maxWidth: 800);
expect(page.provider, isNotNull);
await src.dispose();
final out1 = p.join(tmp.path, 'out1');
expect(await extractZip(zip, out1, tempDir: tmp.path, names: ['readme.txt']), 1);
expect(File(p.join(out1, 'readme.txt')).readAsStringSync(), 'hi');
final out2 = p.join(tmp.path, 'out2');
expect(await extractZip(zip, out2, tempDir: tmp.path, names: ['ch1']), 3); // 폴더째
expect(File(p.join(out2, 'ch1', '10.png')).existsSync(), isTrue);
final out3 = p.join(tmp.path, 'out3');
await extractZip(zip, out3, tempDir: tmp.path);
expect(File(p.join(out3, 'evil.txt')).existsSync(), isTrue); // ../ 는 떼고 안에
expect(File(p.join(tmp.path, 'evil.txt')).existsSync(), isFalse);
});
test('보기 설정 저장 · 읽기', () {
final s = AppSettings()
..reader.imageExts = ['png', 'webp']
..reader.fit = 'width'
..reader.rtl = true
..reader.brightness = 0.3
..reader.zipComic = false;
final b = AppSettings.fromJson(s.toJson());
expect([b.reader.imageExts, b.reader.fit, b.reader.rtl, b.reader.brightness, b.reader.zipComic], [
['png', 'webp'], 'width', true, 0.3, false,
]);
expect(AppSettings.fromJson({}).reader.imageExts, defaultImageExtensions);
});
testWidgets('224: 보는 동안 밖에서 지운 그림 - 앱으로 돌아오면 "파일이 없어졌습니다" 와 다음 그림, 다 없어지면 닫는다', (tester) async {
tester.view.physicalSize = const Size(900, 1200);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final files = [
for (final (i, n) in ['a.png', 'b.png', 'c.png'].indexed)
(File(p.join(tmp.path, n))..writeAsBytesSync(_png(i * 80, 0, 0))).path,
];
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
final src = ImageFilesSource([...files], tempDir: tmp.path, title: 'imgs');
await tester.pumpWidget(MaterialApp(
home: Builder(
builder: (ctx) => TextButton(
onPressed: () => Navigator.of(ctx).push(MaterialPageRoute<void>(builder: (_) => ReaderPage(c: c, source: src, start: 1))),
child: const Text('open')))));
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
expect(find.text('2 / 3'), findsOneWidget);
expect(find.text('b.png'), findsOneWidget);
// 보던 b 를 밖에서 지움 → 돌아오면 c 로
await tester.runAsync(() => File(files[1]).delete());
await ((tester.state(find.byType(ReaderPage)) as dynamic).dropMissingFiles() as Future<void>);
await tester.pumpAndSettle();
expect(find.text('파일이 없어졌습니다'), findsOneWidget);
expect(find.text('c.png'), findsOneWidget);
expect(find.text('2 / 2'), findsOneWidget);
// 나머지도 다 지우면 보기 화면을 닫는다
await tester.runAsync(() async {
await File(files[0]).delete();
await File(files[2]).delete();
});
await ((tester.state(find.byType(ReaderPage)) as dynamic).dropMissingFiles() as Future<void>);
await tester.pumpAndSettle();
expect(find.byType(ReaderPage), findsNothing);
expect(find.text('open'), findsOneWidget);
ScaffoldMessenger.of(tester.element(find.text('open'))).clearSnackBars();
await tester.pumpAndSettle();
});
testWidgets('그림 보기: 오른쪽을 누르면 다음, 왼쪽을 누르면 이전, 가운데는 막대 숨기기 · 오른쪽→왼쪽 넘기기', (tester) async {
tester.view.physicalSize = const Size(900, 1200);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final files = [
for (final (i, n) in ['a.png', 'b.png', 'c.png'].indexed)
(File(p.join(tmp.path, n))..writeAsBytesSync(_png(i * 80, 0, 0))).path,
];
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
final src = ImageFilesSource(files, tempDir: tmp.path, title: 'imgs');
await tester.pumpWidget(MaterialApp(home: ReaderPage(c: c, source: src)));
await tester.pump();
expect(find.text('1 / 3'), findsOneWidget);
expect(find.text('a.png'), findsOneWidget);
Future<void> tapAt(double x) async {
await tester.tapAt(Offset(x, 600));
await tester.pumpAndSettle();
}
await tapAt(850); // 오른쪽 → 다음
expect(find.text('2 / 3'), findsOneWidget);
await tapAt(850);
expect(find.text('3 / 3'), findsOneWidget);
await tapAt(50); // 왼쪽 → 이전
expect(find.text('2 / 3'), findsOneWidget);
await tapAt(450); // 가운데 → 막대 숨김
expect(find.text('2 / 3'), findsNothing);
await tapAt(450);
expect(find.text('2 / 3'), findsOneWidget);
// 만화 (오른쪽 → 왼쪽): 왼쪽을 누르면 다음
await tester.tap(find.byTooltip('넘기는 방향: 왼쪽 → 오른쪽'));
await tester.pumpAndSettle();
expect(c.settings.reader.rtl, isTrue);
await tapAt(50);
expect(find.text('3 / 3'), findsOneWidget);
// 맞추기 · 밝기
await tester.tap(find.byTooltip('좌우 맞추기 (세로로 밀어 봄)'));
await tester.tap(find.byTooltip('밝게'));
await tester.pump();
expect(c.settings.reader.fit, 'width');
expect(c.settings.reader.brightness, closeTo(0.1, 1e-9));
});
testWidgets('30: 두 손가락 확대 → 밀어도 장이 넘어가지 않음 · 더 크게 다시 그림 · 시스템 막대 보이기 설정', (tester) async {
tester.view.physicalSize = const Size(900, 1200);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final files = [
for (final (i, n) in ['a.png', 'b.png'].indexed) (File(p.join(tmp.path, n))..writeAsBytesSync(_png(i * 80, 0, 0))).path,
];
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
final src = _CountingSource(ImageFilesSource(files, tempDir: tmp.path, title: 'imgs'));
await tester.pumpWidget(MaterialApp(home: ReaderPage(c: c, source: src)));
await tester.pumpAndSettle();
expect(src.widths[0], [900]);
// 두 손가락을 벌려 확대
final a = await tester.startGesture(const Offset(400, 600));
final b = await tester.startGesture(const Offset(500, 600));
await tester.pump();
for (var k = 1; k <= 10; k++) {
await a.moveTo(Offset(400 - k * 20.0, 600));
await b.moveTo(Offset(500 + k * 20.0, 600));
await tester.pump();
}
await a.up();
await b.up();
await tester.pumpAndSettle();
expect(src.widths[0]!.last, greaterThan(900)); // 확대한 만큼 크게 다시 읽음
// 확대 중에는 밀어도 다음 장으로 가지 않는다
await tester.dragFrom(const Offset(700, 600), const Offset(-500, 0));
await tester.pumpAndSettle();
expect(find.text('1 / 2'), findsOneWidget);
// 101: 기본은 시스템 막대 보임, 버튼으로 화면 가득 (설정에 남음)
expect(c.settings.reader.systemBars, isTrue);
expect(AppSettings.fromJson({}).reader.systemBars, isTrue);
await tester.tap(find.byTooltip('화면 가득 (시스템 막대 숨기기)'));
await tester.pumpAndSettle();
expect(c.settings.reader.systemBars, isFalse);
expect(AppSettings.fromJson(c.settings.toJson()).reader.systemBars, isFalse);
});
testWidgets('X-plore 맞추기: 아래 갤러리 줄 - 처음은 끔, 켜면 썸네일 · 누르면 그 장으로, 위 막대에서 끄기', (tester) async {
tester.view.physicalSize = const Size(1400, 1200);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
expect(AppSettings().reader.gallery, isFalse, reason: '처음 값은 지금 동작 그대로');
expect(AppSettings.fromJson({'readerGallery': true}).reader.gallery, isTrue);
final files = [
for (final (i, n) in ['a.png', 'b.png', 'c.png'].indexed) (File(p.join(tmp.path, n))..writeAsBytesSync(_png(i * 80, 0, 0))).path,
];
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
await tester.pumpWidget(MaterialApp(home: ReaderPage(c: c, source: ImageFilesSource(files, tempDir: tmp.path, title: 'imgs'))));
await tester.pump();
expect(find.byKey(const ValueKey('thumb0')), findsNothing, reason: '처음은 끔');
await tester.tap(find.byTooltip('아래 갤러리 줄 보이기'));
await tester.pumpAndSettle();
expect(c.settings.reader.gallery, isTrue);
expect(find.byKey(const ValueKey('thumb2')), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('thumb2')));
await tester.pumpAndSettle();
expect(find.text('3 / 3'), findsOneWidget, reason: '누른 장으로');
await tester.tap(find.byTooltip('아래 갤러리 줄 숨기기'));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('thumb0')), findsNothing);
await tester.pumpWidget(const SizedBox());
});
testWidgets('X-plore 맞추기: 볼륨 키로 넘기기 - 처음은 끔 (키를 가로채지 않음), 켜면 아래 = 다음 · 위 = 이전 (Android 만)', (tester) async {
tester.view.physicalSize = const Size(900, 1200);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final was = ReaderPage.volumeKeysSupported;
addTearDown(() => ReaderPage.volumeKeysSupported = was);
ReaderPage.volumeKeysSupported = () => true;
expect(AppSettings().reader.volumeKeys, isFalse);
final files = [
for (final (i, n) in ['a.png', 'b.png', 'c.png'].indexed) (File(p.join(tmp.path, n))..writeAsBytesSync(_png(i * 80, 0, 0))).path,
];
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
await tester.pumpWidget(MaterialApp(home: ReaderPage(c: c, source: ImageFilesSource(files, tempDir: tmp.path, title: 'imgs'))));
await tester.pump();
// 끔: 볼륨 키를 가로채지 않는다 (시스템 볼륨이 바뀜)
expect(await tester.sendKeyEvent(LogicalKeyboardKey.audioVolumeDown), isFalse);
await tester.pumpAndSettle();
expect(find.text('1 / 3'), findsOneWidget);
// 켬
c.settings.reader.volumeKeys = true;
expect(await tester.sendKeyEvent(LogicalKeyboardKey.audioVolumeDown), isTrue, reason: '처리함 - 시스템 볼륨은 바뀌지 않음');
await tester.pumpAndSettle();
expect(find.text('2 / 3'), findsOneWidget);
await tester.sendKeyEvent(LogicalKeyboardKey.audioVolumeDown);
await tester.pumpAndSettle();
expect(find.text('3 / 3'), findsOneWidget);
await tester.sendKeyEvent(LogicalKeyboardKey.audioVolumeUp);
await tester.pumpAndSettle();
expect(find.text('2 / 3'), findsOneWidget);
// Android 가 아니면 쓰지 않음
ReaderPage.volumeKeysSupported = () => false;
expect(await tester.sendKeyEvent(LogicalKeyboardKey.audioVolumeDown), isFalse);
await tester.pumpWidget(const SizedBox());
});
testWidgets('그림이 깨졌으면 화면 글 + [자세히], 파일을 고친 뒤 [다시 시도] 하면 열린다 (28 지적: 같은 키 · 같은 그림이라 Image 가 다시 읽지 않았다 - 장의 키를 올리고 그림 캐시도 비움)', (tester) async {
tester.view.physicalSize = const Size(900, 1200);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final broken = File(p.join(tmp.path, 'broken.png'))..writeAsBytesSync([1, 2, 3, 4, 5]);
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
final src = ImageFilesSource([broken.path], tempDir: tmp.path, title: 'imgs');
Future<void> settle() async {
for (var k = 0; k < 20; k++) {
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 50)));
await tester.pump();
}
}
await tester.pumpWidget(MaterialApp(home: ReaderPage(c: c, source: src)));
await settle();
expect(find.text('그림 파일을 읽을 수 없습니다 - 깨졌거나 지원하지 않는 형식'), findsOneWidget, reason: '원문 대신 화면 글');
expect(find.text('자세히'), findsOneWidget, reason: '원문은 [자세히] 에');
// 파일을 고치고 [다시 시도]
broken.writeAsBytesSync(_png(0, 200, 0));
await tester.tap(find.text('다시 시도'));
await settle();
expect(find.text('그림 파일을 읽을 수 없습니다 - 깨졌거나 지원하지 않는 형식'), findsNothing);
expect(find.byType(RawImage), findsWidgets, reason: '고친 그림이 보임');
await tester.pumpWidget(const SizedBox());
});
}
/// 읽은 너비를 기록 (확대하면 더 크게 다시 읽는지)
class _CountingSource implements ReaderSource {
final ReaderSource inner;
final widths = <int, List<int>>{};
_CountingSource(this.inner);
@override
String get title => inner.title;
@override
int get length => inner.length;
@override
String pageName(int i) => inner.pageName(i);
@override
Future<ReaderImage> load(int i, {required int maxWidth}) {
(widths[i] ??= []).add(maxWidth);
return inner.load(i, maxWidth: maxWidth);
}
@override
Future<void> dispose() => inner.dispose();
}