import 'dart:async'; import 'dart:ui' as ui; import 'package:flutter/material.dart'; 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/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'; /// 크기를 정한 그림들 (가로로 긴 것 = 두 쪽이 붙은 그림) class _Sized extends ReaderSource { final List images; final widths = >{}; _Sized(this.images); @override String get title => 'comic'; @override int get length => images.length; @override String pageName(int i) => 'p$i.png'; @override Future load(int i, {required int maxWidth}) async { (widths[i] ??= []).add(maxWidth); return ReaderImage(image: images[i].clone()); } } /// 관리자 지시 10/20: WebDAV 처럼 느린 원본 - [hold] 의 장은 [release] 할 때까지 받는 중 class _Slow extends _Sized { final int hold; final release = Completer(); _Slow(super.images, this.hold); @override Future load(int i, {required int maxWidth}) async { if (i == hold) await release.future; return super.load(i, maxWidth: maxWidth); } } /// 223: 폴더의 그림들처럼 (자동 나누기는 다른 그림과 견주어 판단) class _FolderSized extends ImageFilesSource { final List images; _FolderSized(this.images) : super([for (var i = 0; i < images.length; i++) 'p$i.png'], tempDir: '.', title: 'photos'); @override Future load(int i, {required int maxWidth}) async => ReaderImage(image: images[i].clone()); } void main() { late List screenOn; setUp(() { screenOn = []; ReaderPage.keepScreenOn = (on) async => screenOn.add(on); }); AppController controller() => AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); Future<_Sized> source(WidgetTester t, List<(int, int)> sizes) async { final images = []; await t.runAsync(() async { for (final (w, h) in sizes) { images.add(await createTestImage(width: w, height: h)); } }); return _Sized(images); } Future open(WidgetTester t, AppController c, ReaderSource src, {double width = 900, int start = 0}) async { t.view.physicalSize = Size(width, 1200); t.view.devicePixelRatio = 1; addTearDown(t.view.reset); await t.pumpWidget(MaterialApp(home: ReaderPage(c: c, source: src, start: start))); await t.pumpAndSettle(); } Future tapAt(WidgetTester t, double x) async { await t.tapAt(Offset(x, 600)); await t.pumpAndSettle(); } test('117 · 119 설정: 처음 값 3초 · 자동, 1 ~ 60 초', () { final s = AppSettings.fromJson({}); expect((s.readerAutoSeconds, s.readerSplit), (3, 'auto')); final b = AppSettings.fromJson((AppSettings() ..readerAutoSeconds = 12 ..readerSplit = 'on') .toJson()); expect((b.readerAutoSeconds, b.readerSplit), (12, 'on')); expect(AppSettings.fromJson({'readerAutoSeconds': 500}).readerAutoSeconds, 60); expect(AppSettings.fromJson({'readerAutoSeconds': 0}).readerAutoSeconds, 1); expect(AppSettings.fromJson({'readerSplit': 'x'}).readerSplit, 'auto'); }); testWidgets('119: 자동이면 가로로 긴 그림만 반씩 (12-1 · 12-2), 표지 (세로) 는 통째 · 반쪽은 두 배 너비로 읽음', (t) async { final c = controller(); final src = await source(t, [(100, 200), (200, 100), (100, 200)]); await open(t, c, src); expect(find.text('1 / 3'), findsOneWidget); await tapAt(t, 850); expect(find.text('2-1 / 3장'), findsOneWidget); expect(src.widths[1], contains(1800), reason: '반쪽이 화면 가득이어도 흐리지 않게'); await tapAt(t, 850); expect(find.text('2-2 / 3장'), findsOneWidget); await tapAt(t, 850); expect(find.text('3 / 3장'), findsOneWidget); await tapAt(t, 50); expect(find.text('2-2 / 3장'), findsOneWidget); // 버튼: 자동 → 켜기 (모든 장) → 끄기 → 자동 await t.tap(find.byTooltip('두 쪽 나눠 보기: 자동 - 두 쪽 스캔으로 보이는 그림만 (누르면 켜기)')); await t.pumpAndSettle(); expect(c.settings.readerSplit, 'on'); expect(find.text('2-2 / 3장'), findsOneWidget, reason: '보던 쪽 그대로'); await tapAt(t, 850); expect(find.text('3-1 / 3장'), findsOneWidget); await t.tap(find.byTooltip('두 쪽 나눠 보기: 켜기 (누르면 끄기)')); await t.pumpAndSettle(); expect(c.settings.readerSplit, 'off'); expect(find.text('3 / 3'), findsOneWidget); await t.tap(find.byTooltip('두 쪽 나눠 보기: 끄기 (누르면 자동)')); await t.pumpAndSettle(); expect(c.settings.readerSplit, 'auto'); }); test('223: 자동 나누기 판단 - 묶음 (ZIP · CBZ · PDF) 의 가로 그림은 나눔, 폴더는 다른 그림 대부분이 세로일 때만', () { const wide = Size(480, 320), tall = Size(320, 480); expect(autoSplitsPage(wide, const [], bundle: true), isTrue); expect(autoSplitsPage(tall, const [], bundle: true), isFalse); // 사진 폴더: 가로 사진 한 장 · 가로 사진들 사이 → 나누지 않음 expect(autoSplitsPage(wide, const [], bundle: false), isFalse); expect(autoSplitsPage(wide, const [wide, wide, tall], bundle: false), isFalse); // 만화 폴더 (낱장 세로들 사이의 펼침 한 장) → 나눔 expect(autoSplitsPage(wide, const [tall, tall, tall, tall], bundle: false), isTrue); expect(autoSplitsPage(wide, const [tall], bundle: false), isFalse, reason: '비교할 그림이 너무 적으면 나누지 않음'); }); testWidgets('223: 폴더의 평범한 가로 사진 (480×320) 은 자동이어도 반으로 자르지 않는다', (t) async { final c = controller(); final images = []; await t.runAsync(() async => images.add(await createTestImage(width: 480, height: 320))); await open(t, c, _FolderSized(images)); expect(find.text('1 / 1'), findsOneWidget, reason: '"1-1 / 1장" 처럼 나뉘지 않음'); expect(find.byTooltip('두 쪽 나눔 끄기 (한 장으로 보기)'), findsNothing); }); testWidgets('223: 나눠 본 장에서는 위 막대의 [두 쪽 나눔 끄기] 로 바로 한 장으로', (t) async { final c = controller(); final src = await source(t, [(200, 100)]); await open(t, c, src); expect(find.text('1-1 / 1장'), findsOneWidget); await t.tap(find.byTooltip('두 쪽 나눔 끄기 (한 장으로 보기)')); await t.pumpAndSettle(); expect(c.settings.readerSplit, 'off'); expect(find.text('1 / 1'), findsOneWidget); }); testWidgets('119: 오른쪽 → 왼쪽으로 읽으면 오른쪽 반부터', (t) async { final c = controller()..settings.readerRtl = true; final src = await source(t, [(200, 100)]); await open(t, c, src); expect(find.text('1-1 / 1장'), findsOneWidget); Align half() => t.widget(find.descendant(of: find.byType(ClipRect), matching: find.byType(Align)).first); expect(half().alignment, Alignment.centerRight); await tapAt(t, 50); // 만화: 왼쪽 = 다음 expect(find.text('1-2 / 1장'), findsOneWidget); expect(half().alignment, Alignment.centerLeft); }); testWidgets('120: 회전하면 회전한 뒤의 가로 · 세로로 나누기를 다시 판단 · 상하 반전 · 파일은 그대로', (t) async { final c = controller(); final src = await source(t, [(100, 200), (100, 200)]); await open(t, c, src); expect(find.text('1 / 2'), findsOneWidget); await t.tap(find.byTooltip('90° 회전 (시계 방향)')); await t.pumpAndSettle(); expect(t.widget(find.descendant(of: find.byType(FittedBox), matching: find.byType(RotatedBox)).first) .quarterTurns, 1); expect(find.text('1-1 / 2장'), findsOneWidget, reason: '돌리니 가로로 길어져 반씩'); await t.tap(find.byTooltip('반대로 90° 회전')); await t.pumpAndSettle(); expect(find.text('1 / 2'), findsOneWidget); expect(find.descendant(of: find.byType(FittedBox), matching: find.byType(RotatedBox)), findsNothing); await t.tap(find.byTooltip('상하 반전')); await t.pumpAndSettle(); expect(find.byWidgetPredicate((w) => w is Transform && w.transform.storage[5] == -1), findsWidgets); // 다음 장에도 같이 await tapAt(t, 850); expect(find.text('2 / 2'), findsOneWidget); expect(find.byWidgetPredicate((w) => w is Transform && w.transform.storage[5] == -1), findsWidgets); }); testWidgets('117: 계속 보기 - 정한 초마다 다음 장, ▲ · ▼ 로 간격, 마지막 장에서 멈춤, 화면 켜 두기', (t) async { final c = controller(); final src = await source(t, [(100, 200), (100, 200), (100, 200), (100, 200)]); await open(t, c, src); await t.tap(find.byTooltip('계속 보기 간격 늘리기 (지금 3초)')); await t.pumpAndSettle(); expect(c.settings.readerAutoSeconds, 4); expect(find.text('4초'), findsOneWidget); await t.tap(find.byTooltip('계속 보기 간격 줄이기 (지금 4초)')); await t.pumpAndSettle(); await t.tap(find.byTooltip('계속 보기 간격 줄이기 (지금 3초)')); await t.pumpAndSettle(); expect(c.settings.readerAutoSeconds, 2); await t.tap(find.byTooltip('계속 보기 (2초마다 다음 장)')); await t.pump(); expect(screenOn, [true]); await t.pump(const Duration(milliseconds: 1900)); expect(find.text('1 / 4'), findsOneWidget); await t.pump(const Duration(milliseconds: 200)); expect(find.text('2 / 4'), findsOneWidget); // 관리자 지시 10/20: 시계는 그 장이 다 보인 뒤부터 - 넘긴 뒤 한 장면 (FutureBuilder 가 그림을 받아 그림) 이 지나야 잰다 await t.pump(); await t.pump(const Duration(milliseconds: 1900)); expect(find.text('2 / 4'), findsOneWidget); await t.pump(const Duration(milliseconds: 200)); expect(find.text('3 / 4'), findsOneWidget); await t.pump(); await t.pump(const Duration(seconds: 2)); expect(find.text('4 / 4'), findsOneWidget); await t.pump(); await t.pump(const Duration(seconds: 2)); expect(find.byTooltip('계속 보기 (2초마다 다음 장)'), findsOneWidget, reason: '마지막 장에서 멈춤'); expect(screenOn, [true, false]); await t.pumpAndSettle(const Duration(seconds: 3)); }); testWidgets('117 (관리자 지시 10/20): 느린 원본 - 받는 동안은 넘기지 않고, 다 보인 뒤부터 정한 초를 잰다', (t) async { final c = controller(); final sized = await source(t, [(100, 200), (100, 200), (100, 200)]); final src = _Slow(sized.images, 1); await open(t, c, src); await t.tap(find.byTooltip('계속 보기 (3초마다 다음 장)')); await t.pump(); await t.pump(const Duration(seconds: 3)); expect(find.text('2 / 3'), findsOneWidget); // 2 장은 받는 중 - 한참 지나도 넘기지 않는다 for (var k = 0; k < 10; k++) { await t.pump(const Duration(seconds: 1)); } expect(find.text('2 / 3'), findsOneWidget, reason: '받는 동안 시계가 흐르지 않음'); expect(find.byType(CircularProgressIndicator), findsOneWidget); // 다 받으면 그때부터 3초 src.release.complete(); await t.pump(); await t.pump(); await t.pump(const Duration(milliseconds: 2900)); expect(find.text('2 / 3'), findsOneWidget); await t.pump(const Duration(milliseconds: 200)); expect(find.text('3 / 3'), findsOneWidget); await t.tap(find.byTooltip('계속 보기 멈추기')); await t.pumpAndSettle(); }); testWidgets('117: 화면을 누르면 멈추고 그 누름으로는 넘기지 않음 · 손으로 밀어도 멈춤', (t) async { final c = controller(); final src = await source(t, [(100, 200), (100, 200), (100, 200), (100, 200)]); await open(t, c, src); await t.tap(find.byTooltip('계속 보기 (3초마다 다음 장)')); await t.pump(); await tapAt(t, 850); expect(find.text('1 / 4'), findsOneWidget); expect(find.byTooltip('계속 보기 (3초마다 다음 장)'), findsOneWidget); expect(screenOn, [true, false]); await t.pump(const Duration(seconds: 7)); expect(find.text('1 / 4'), findsOneWidget); // 다음 누름은 다시 넘긴다 await tapAt(t, 850); expect(find.text('2 / 4'), findsOneWidget); // 밀어서 멈춤 (민 만큼은 넘어감) await t.tap(find.byTooltip('계속 보기 (3초마다 다음 장)')); await t.pump(); await t.dragFrom(const Offset(700, 600), const Offset(-500, 0)); await t.pumpAndSettle(); expect(find.text('3 / 4'), findsOneWidget); await t.pump(const Duration(seconds: 7)); expect(find.text('3 / 4'), findsOneWidget); expect(screenOn, [true, false, true, false]); await t.pumpAndSettle(const Duration(seconds: 3)); }); testWidgets('좁은 화면 (폰 세로): 못 들어간 버튼은 ⋮ 메뉴로 - 없어지지 않음', (t) async { final c = controller(); final src = await source(t, [(100, 200), (100, 200)]); await open(t, c, src, width: 400); expect(find.byTooltip('더 보기'), findsOneWidget); expect(find.byTooltip('계속 보기 (3초마다 다음 장)'), findsOneWidget, reason: '계속 보기는 막대에 남음'); expect(find.byTooltip('넘기는 방향: 왼쪽 → 오른쪽'), findsNothing); await t.tap(find.byTooltip('더 보기')); await t.pumpAndSettle(); for (final s in ['상하 반전', '한 쪽 맞추기', '넘기는 방향: 왼쪽 → 오른쪽', '밝게', '화면 가득 (시스템 막대 숨기기)']) { expect(find.text(s), findsOneWidget, reason: s); } await t.tap(find.text('넘기는 방향: 왼쪽 → 오른쪽')); await t.pumpAndSettle(); expect(c.settings.readerRtl, isTrue); // 넓으면 메뉴 없이 모두 await open(t, c, src, width: 1200); expect(find.byTooltip('더 보기'), findsNothing); expect(find.byTooltip('상하 반전'), findsOneWidget); }); }