JJ_MKVMaker/integration_test/windows_mkvdetail_shot_test...

147 lines
7.9 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:jj_mkvmaker/app/app_controller.dart';
import 'package:jj_mkvmaker/app/i18n_controller.dart';
import 'package:jj_mkvmaker/core/models.dart';
import 'package:jj_mkvmaker/core/output_paths.dart';
import 'package:jj_mkvmaker/l10n/tr.dart';
import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
import 'package:jj_mkvmaker/services/media_tool.dart';
import 'package:jj_mkvmaker/services/platform_services.dart';
import 'package:jj_mkvmaker/ui/home_page.dart';
import 'package:jj_mkvmaker/ui/theme.dart';
import 'package:path/path.dart' as p;
import 'support/temp_dir.dart';
/// 동영상 하나 (음성 2 · 자막 1) 로 보이는 가짜 FFmpeg (실제로 만들지 않음)
class _Tool implements MediaTool {
@override
Future<String?> version() async => 'ffmpeg test';
@override
Future<Set<String>> encoders() async => {'libx264'};
@override
Future<MediaInfo> probe(String path) async => MediaInfo(duration: const Duration(minutes: 24), streams: [
const StreamInfo(index: 0, type: 'video', codec: 'h264', width: 1920, height: 1080),
const StreamInfo(index: 1, type: 'audio', codec: 'aac', language: 'jpn'),
const StreamInfo(index: 2, type: 'audio', codec: 'aac', language: 'kor'),
const StreamInfo(index: 3, type: 'subtitle', codec: 'subrip', language: 'kor'),
]);
@override
Future<void> runFfmpeg(List<String> args, {Duration? duration, ProgressCallback? onProgress, Object? tag}) async {}
@override
void cancel() {}
@override
void cancelTagged(Object tag) {}
}
/// F5 · 206 (관리자 지시 10/21): MKV 자세히 칸 - 오른쪽 아래 떠 있는 [이동] 단추가 마지막 줄 ("출력: …") 이나 칩을 가리지 않는다.
/// 넓은 화면은 끝까지 내리면 마지막 줄이 단추 위로 (목록 끝 여백), 좁은 화면 (412) 은 단추가 칩 줄 아래에 놓인다.
/// 412 · 960×600 · 1280×720 × 글자 1.0 · 1.6 · 낮은 화면 900×412 · 1280×480 (F7) × ko · ja. [자막 파일 추가] 도 단추에 가리지 않는다. 단추와 출력 줄 · 칩이 겹치는지 자리로 잰다
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
// ⑦ 밝은 화면 캡처: JJ_SHOT_THEME=light (JJ_SHOT_DIR 를 따로 두어 어두운 것과 견줌)
if (Platform.environment['JJ_SHOT_THEME'] == 'light') JjColors.use(Brightness.light);
final out = Platform.environment['JJ_SHOT_DIR'] ?? '';
for (final lang in ['ko', 'ja']) {
for (final (w, h, scale) in [
(412, 900, 1.0), (412, 900, 1.6), (960, 600, 1.0), (960, 600, 1.6), (1280, 720, 1.0), (1280, 720, 1.6),
// F7 (10/21): 높이가 낮은 화면 (폰 가로 · 낮은 창) - [이동] 이 [자막 파일 추가] 를 가렸다
(900, 412, 1.0), (900, 412, 1.6), (1280, 480, 1.0),
]) {
testWidgets('MKV 자세히 칸 이동 단추 $lang ${w}x$h 글자 $scale', (t) async {
t.view.physicalSize = Size(w.toDouble(), h.toDouble());
t.view.devicePixelRatio = 1;
addTearDown(t.view.reset);
await t.runAsync(() => i18n.apply(lang, save: false));
addTearDown(() => i18n.apply('ko', save: false));
final tmp = testTempDir('jj_mkvdetail_');
appTempInto(tmp);
final file = (File(p.join(tmp.path, '[시험] 아주 긴 이름의 동영상 - 1화 고화질.mp4'))..writeAsBytesSync([0])).path;
// 같은 폴더의 외부 자막 (문자셋 고르기까지 든 줄 - F6)
File(p.join(tmp.path, '[시험] 아주 긴 이름의 동영상 - 1화 고화질.ko.srt')).writeAsStringSync('1\n00:00:01,000 --> 00:00:02,000\n안녕\n');
final c = AppController(PlatformServices(mediaTool: _Tool(), storage: DesktopStorageService()));
await t.runAsync(() async {
await c.init();
await c.addVideos([file]);
});
final shotKey = GlobalKey();
await t.pumpWidget(RepaintBoundary(
key: shotKey,
child: MaterialApp(
theme: buildTheme(),
debugShowCheckedModeBanner: false,
builder: (ctx, child) => MediaQuery(
data: MediaQuery.of(ctx).copyWith(textScaler: TextScaler.linear(scale)), child: child!),
home: HomePage(c: c),
),
));
Future<void> settle([int n = 8]) async {
for (var i = 0; i < n; i++) {
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 50)));
await t.pump();
}
}
Future<void> shot(String name) async {
await settle(2);
if (out.isEmpty) return;
final r = shotKey.currentContext!.findRenderObject()! as RenderRepaintBoundary;
final img = await t.runAsync(() => r.toImage());
final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png));
Directory(out).createSync(recursive: true);
File(p.join(out, 'mkvdetail_${name}_${lang}_${w}x${h}_s$scale.png')).writeAsBytesSync(bytes!.buffer.asUint8List());
}
await settle();
// 좁은 화면은 목록 · 자세히가 위아래 - 동영상 줄을 눌러 자세히를 연다 (넓은 화면은 처음부터 보임)
final outputLine = find.text(trf('출력: {0}', [outputMkvPath(file)]));
if (outputLine.evaluate().isEmpty && find.text(tr('자막')).evaluate().isEmpty) {
await t.tap(find.textContaining('아주 긴 이름의 동영상').first);
await settle();
}
expect(t.takeException(), isNull, reason: '넘침 (처음 자리)');
final fab = find.byType(FloatingActionButton);
// 206: 처음 자리에서 칩 (음성 n개 등) 이 [이동] 단추에 가리지 않음 (좁은 화면은 단추가 칩 줄 아래에 놓임)
if (fab.evaluate().isNotEmpty) {
final b = t.getRect(fab.first);
for (final text in [trf('음성 {0}개', [2]), trf('영상 {0}', ['h264']), '1920×1080', tr('자막 파일 추가')]) {
if (find.text(text).evaluate().isEmpty) continue;
final r = t.getRect(find.text(text));
expect(r.overlaps(b), isFalse, reason: '칩 "$text" 이 단추에 가리지 않음 ($r · 단추 $b)');
}
}
// 글자가 크면 출력 줄이 아래에 있어 아직 만들어지지 않았을 수 있다 - 자세히 칸을 (칩 자리에서) 끌어 내려 찾는다
if (outputLine.evaluate().isEmpty) {
final down = find.byWidgetPredicate((x) => x is Scrollable && x.axisDirection == AxisDirection.down);
final pane = t.state<ScrollableState>(find.ancestor(of: find.text('1920×1080'), matching: down).first);
for (var i = 0; i < 10 && outputLine.evaluate().isEmpty; i++) {
pane.position.jumpTo(pane.position.maxScrollExtent);
await settle(3);
}
}
expect(outputLine, findsOneWidget, reason: '자세히 칸의 출력 줄');
await shot('1_top');
expect(t.takeException(), isNull, reason: '넘침 (출력 줄까지)');
// F5: 자세히 칸을 끝까지 내리면 출력 줄이 단추 위로 (목록 끝 여백)
final scroll = t.state<ScrollableState>(find.ancestor(of: outputLine, matching: find.byType(Scrollable)).first);
scroll.position.jumpTo(scroll.position.maxScrollExtent);
await settle();
await shot('2_end');
expect(t.takeException(), isNull, reason: '넘침 (끝)');
if (fab.evaluate().isNotEmpty && outputLine.evaluate().isNotEmpty) {
final b = t.getRect(fab.first), line = t.getRect(outputLine);
expect(line.overlaps(b), isFalse, reason: '끝까지 내리면 출력 줄이 단추에 가리지 않음 (줄 $line · 단추 $b)');
}
expect(c.videos.single.status, isNot(JobStatus.running));
});
}
}
}