306 lines
15 KiB
Dart
306 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;
|
|
import 'package:jj_mkvmaker/services/platform_info.dart';
|
|
|
|
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);
|
|
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,
|
|
];
|
|
// 설계 M4: Android 인지는 PlatformInfo 주입 (예전 전역 함수 ReaderPage.volumeKeysSupported)
|
|
final c = AppController(
|
|
PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService(), platform: PlatformInfo.android));
|
|
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 가 아니면 쓰지 않음 (같은 설정 · Windows 조립)
|
|
final win = AppController(
|
|
PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService(), platform: PlatformInfo.windows));
|
|
win.settings.reader.volumeKeys = true;
|
|
await tester.pumpWidget(MaterialApp(home: ReaderPage(c: win, source: ImageFilesSource(files, tempDir: tmp.path, title: 'imgs'))));
|
|
await tester.pump();
|
|
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();
|
|
|
|
}
|