From 4ebc4f21ccf045223cc0a3a72d9c4cae7c8d85b5 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:44:20 +0900 Subject: [PATCH] =?UTF-8?q?WIP=20=E2=91=A6=20=EB=B0=9D=EC=9D=80=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EC=BA=A1=EC=B2=98=20=EC=8B=9C=ED=97=98=20?= =?UTF-8?q?=EB=84=93=ED=9E=98:=20=EB=B3=B4=EA=B4=80=ED=95=A8=20=C2=B7=20DL?= =?UTF-8?q?NA=20=C2=B7=20=EC=95=B1=20=EA=B4=80=EB=A6=AC=EC=9E=90=20=C2=B7?= =?UTF-8?q?=20Tor=20=C2=B7=20=EC=9E=90=EB=A7=89=20=ED=8E=B8=EC=A7=91=20?= =?UTF-8?q?=C2=B7=20Wi-Fi=20=EA=B3=B5=EC=9C=A0=20=C2=B7=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EB=B3=B4=EC=A0=95=20=C2=B7=20662=20=ED=9D=94?= =?UTF-8?q?=EB=93=A4=EB=A6=BC=20=EA=B3=A0=EC=B9=A8=20(=EA=B2=80=ED=86=A0?= =?UTF-8?q?=20=EB=8C=80=EA=B8=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 관리자 지시 10/21: - 캡처가 없던 화면 캡처 시험 (windows_vault_shot_test · windows_misc_shot_test), Wi-Fi 공유 · 화면 보정 캡처에도 JJ_SHOT_THEME - 662 캡처 흔들림: 크기마다 새 Navigator (앞 크기에서 밀어 연 화면이 남아 다음 첫 캡처가 다른 화면이 됨) · 밀기 그림이 끝날 때까지 기다림 - 확인: 밝은 화면 캡처를 보고 읽기 어려운 곳 없음, 어두운 화면은 0a9a2d0 과 픽셀로 견줘 흔들림 밖 차이 0, 662 는 두 번 찍어 같음 Co-Authored-By: Claude Opus 5.5 --- integration_test/video_adjust_e2e_test.dart | 2 + integration_test/windows_662_shot_test.dart | 7 + integration_test/windows_misc_shot_test.dart | 127 ++++++++++++++++++ integration_test/windows_vault_shot_test.dart | 86 ++++++++++++ integration_test/windows_wifi_shot_test.dart | 2 + 5 files changed, 224 insertions(+) create mode 100644 integration_test/windows_misc_shot_test.dart create mode 100644 integration_test/windows_vault_shot_test.dart diff --git a/integration_test/video_adjust_e2e_test.dart b/integration_test/video_adjust_e2e_test.dart index 0d91ee4..2216536 100644 --- a/integration_test/video_adjust_e2e_test.dart +++ b/integration_test/video_adjust_e2e_test.dart @@ -18,6 +18,8 @@ import 'package:path/path.dart' as p; /// 실제 FFmpeg: 가로 영상 → 화면 · 색 보정 창 미리보기 → 세로 9:16 + 채도 MKV 만들기 void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + // ⑦ 밝은 화면 캡처: JJ_SHOT_THEME=light + if (Platform.environment['JJ_SHOT_THEME'] == 'light') JjColors.use(Brightness.light); testWidgets('화면 · 색 보정: 미리보기와 세로 9:16 MKV', (tester) async { final dir = testTempDir('jj_adj_e2e_'); diff --git a/integration_test/windows_662_shot_test.dart b/integration_test/windows_662_shot_test.dart index 5781cf7..4b53de6 100644 --- a/integration_test/windows_662_shot_test.dart +++ b/integration_test/windows_662_shot_test.dart @@ -53,7 +53,9 @@ void main() { Future showHome(WidgetTester t, AppController c, Size size) async { t.view.physicalSize = size; t.view.devicePixelRatio = 1; + // 새 Navigator 로 (앞 크기에서 밀어 연 화면이 남지 않게 - 남으면 다음 크기의 첫 캡처가 다른 화면이 됨) await t.runAsync(() => t.pumpWidget(MaterialApp( + key: UniqueKey(), theme: buildTheme(), builder: (_, child) => AppScope(controller: c, child: RepaintBoundary(key: key, child: child!)), home: KeyedSubtree(key: UniqueKey(), child: HomePage(c: c)), @@ -64,6 +66,11 @@ void main() { Future swipe(WidgetTester t, Size size, double dx) async { await t.flingFrom(Offset(size.width / 2, size.height / 2), Offset(dx, 0), 2500); await settle(t, 8); + // 밀기 그림 (화면 전환) 이 다 끝날 때까지 - 그리기가 더 남아 있지 않을 때까지 (최대 5초) + for (var i = 0; i < 50 && t.binding.hasScheduledFrame; i++) { + await t.runAsync(() => Future.delayed(const Duration(milliseconds: 100))); + await t.pump(); + } } testWidgets('66-2: 지금 화면 아이콘 (넓은 · 좁은 화면) · 처음으로 알림', (t) async { diff --git a/integration_test/windows_misc_shot_test.dart b/integration_test/windows_misc_shot_test.dart new file mode 100644 index 0000000..7d9b4b2 --- /dev/null +++ b/integration_test/windows_misc_shot_test.dart @@ -0,0 +1,127 @@ +import 'dart:io'; +import 'dart:typed_data'; +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/ui/app_actions.dart'; +import 'package:jj_mkvmaker/app/subtitle_editor_controller.dart'; +import 'package:jj_mkvmaker/core/app_manager.dart'; +import 'package:jj_mkvmaker/core/dlna.dart'; +import 'package:jj_mkvmaker/core/languages.dart'; +import 'package:jj_mkvmaker/core/models.dart'; +import 'package:jj_mkvmaker/core/srt.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/app_manager_page.dart'; +import 'package:jj_mkvmaker/ui/dlna_page.dart'; +import 'package:jj_mkvmaker/ui/subtitle_editor_page.dart'; +import 'package:jj_mkvmaker/ui/theme.dart'; +import 'package:jj_mkvmaker/ui/tor_page.dart'; +import 'package:path/path.dart' as p; + +/// ⑦ 밝은 화면 (관리자 지시 10/21): 캡처가 없던 화면 (DLNA · 앱 관리자 · Tor · 자막 편집) 을 PNG 로 (JJ_SHOT_DIR, JJ_SHOT_THEME=light). +/// 기기 · 네트워크 대신 가짜를 쓰고, 설정은 메모리에만 +class _Apps implements AppManagerBackend { + @override + Future> list() async => [ + for (final (i, n) in ['카메라', 'Chrome', 'JJ_MKVMaker', '메모'].indexed) + InstalledApp( + package: 'com.x.$i', + label: n, + version: '1.$i', + apk: '', + installed: DateTime(2026, 1, i + 1), + updated: DateTime(2026, 2, i + 1), + size: (i + 1) * 12000000, + system: i == 0, + splits: 0), + ]; + @override + Future icon(String package) async => null; + @override + Future details(String package) async => true; + @override + Future uninstall(String package) async => true; + @override + Future backupDir() async => Directory.systemTemp.path; +} + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + if (Platform.environment['JJ_SHOT_THEME'] == 'light') JjColors.use(Brightness.light); + + testWidgets('캡처 없던 화면 그림', (t) async { + final out = Platform.environment['JJ_SHOT_DIR'] ?? p.join(Directory.systemTemp.path, 'jj_shots_misc'); + Directory(out).createSync(recursive: true); + final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + c.settings + ..torNoticeSeen = true + ..components = [...c.settings.components, 'tor']; + final key = GlobalKey(); + + Future shot(String name, Size size, Widget home, {Future Function()? then}) async { + t.view.physicalSize = size; + t.view.devicePixelRatio = 1; + await t.runAsync(() => t.pumpWidget(MaterialApp( + key: UniqueKey(), + theme: buildTheme(), + debugShowCheckedModeBanner: false, + builder: (_, child) => AppScope(controller: c, child: RepaintBoundary(key: key, child: child!)), + home: home, + ))); + for (var i = 0; i < 15; i++) { + await t.runAsync(() => Future.delayed(const Duration(milliseconds: 100))); + await t.pump(const Duration(milliseconds: 50)); + } + if (then != null) { + await then(); + for (var i = 0; i < 10; i++) { + await t.runAsync(() => Future.delayed(const Duration(milliseconds: 100))); + await t.pump(const Duration(milliseconds: 50)); + } + } + final boundary = key.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final image = await t.runAsync(() => boundary.toImage()); + final bytes = await t.runAsync(() => image!.toByteData(format: ui.ImageByteFormat.png)); + File(p.join(out, 'misc_$name.png')).writeAsBytesSync(bytes!.buffer.asUint8List()); + await t.pumpWidget(const SizedBox()); + } + + const nas = DlnaServer('집 NAS', 'http://192.168.0.5/d.xml', 'http://192.168.0.5/ctl'); + Widget dlna() => DlnaPage( + c: c, + discover: () async => [nas], + browse: (s, id, start) async => ( + entries: [ + const DlnaEntry('v', '동영상', isContainer: true, childCount: 2), + const DlnaEntry('x', '여행.mp4', upnpClass: 'object.item.videoItem'), + ], + total: 2 + ), + ); + await shot('dlna_servers', const Size(960, 600), dlna()); + await shot('dlna_folder', const Size(960, 600), dlna(), then: () => t.tap(find.text('집 NAS'))); + await shot('apps_412', const Size(412, 800), AppManagerPage(backend: _Apps())); + await shot('tor_960', const Size(960, 600), TorPage( + c: c, + startTor: () async {}, + missingFeatures: () async => const [], + applyProxy: () async => true, + onLeft: () async {}, + viewBuilder: (_, _) => const ColoredBox(color: Colors.white), + )); + final v = VideoItem(r'D:\m\영화.mkv'); + final s = SubtitleEntry.external(path: r'D:\m\영화.ko.srt', language: languageOf('ko')); + v.subtitles.add(s); + final editor = SubtitleEditorController( + parseSrt('1\n00:00:01,000 --> 00:00:03,000\n첫째\n\n2\n00:00:05,000 --> 00:00:07,000\n둘째\n\n3\n00:00:09,000 --> 00:00:11,000\n셋째\n'), + saveCharset: 'UTF-8'); + await shot('subtitle_960', const Size(960, 600), SubtitleEditorPage(app: c, video: v, entry: s, editor: editor)); + t.view.reset(); + }); +} diff --git a/integration_test/windows_vault_shot_test.dart b/integration_test/windows_vault_shot_test.dart new file mode 100644 index 0000000..12ffd1f --- /dev/null +++ b/integration_test/windows_vault_shot_test.dart @@ -0,0 +1,86 @@ +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/core/vault_store.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/theme.dart'; +import 'package:jj_mkvmaker/ui/vault_page.dart'; +import 'package:path/path.dart' as p; + +/// 암호 보관함 화면 (만들기 · 목록 (폴더 · 파일)) 을 PNG 로 (JJ_SHOT_DIR, JJ_SHOT_THEME=light 면 밝은 화면). +/// 보관함 · 시험 파일은 임시 폴더에만 만들고 끝나면 지운다 +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + if (Platform.environment['JJ_SHOT_THEME'] == 'light') JjColors.use(Brightness.light); + + testWidgets('보관함 화면 그림', (t) async { + final out = Platform.environment['JJ_SHOT_DIR'] ?? p.join(Directory.systemTemp.path, 'jj_shots_vault'); + Directory(out).createSync(recursive: true); + final tmp = Directory.systemTemp.createTempSync('jj_vaultshot_'); + addTearDown(() => tmp.deleteSync(recursive: true)); + final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + c.settings.vaultEnabled = true; + final key = GlobalKey(); + + Future shot(String name, Size size, Directory dir) async { + vaultDirOf = () async => dir; + t.view.physicalSize = size; + t.view.devicePixelRatio = 1; + await t.runAsync(() => t.pumpWidget(RepaintBoundary( + key: key, child: MaterialApp(theme: buildTheme(), debugShowCheckedModeBanner: false, home: VaultPage(c: c))))); + for (var i = 0; i < 15; i++) { + await t.runAsync(() => Future.delayed(const Duration(milliseconds: 100))); + await t.pump(const Duration(milliseconds: 50)); + } + final boundary = key.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final image = await t.runAsync(() => boundary.toImage()); + final bytes = await t.runAsync(() => image!.toByteData(format: ui.ImageByteFormat.png)); + File(p.join(out, 'vault_$name.png')).writeAsBytesSync(bytes!.buffer.asUint8List()); + await t.pumpWidget(const SizedBox()); + } + + // 만들기 화면 + await shot('create_412', const Size(412, 800), Directory(p.join(tmp.path, 'new'))); + // 열린 목록: 보관함을 미리 만들고 [열기] 까지 + final dir = Directory(p.join(tmp.path, 'vault')); + final v = await t.runAsync(() => Vault.create(dir, 'pw', iterations: 1000)); + final f = File(p.join(tmp.path, '사진.jpg'))..writeAsBytesSync(List.filled(2048, 7)); + await t.runAsync(() async { + await v!.add(f.path); + await v.add(f.path, folder: '여행'); + await v.mkdir('빈 폴더'); + }); + vaultIterationsForTest = 1000; + for (final (name, size) in [('open_412', const Size(412, 800)), ('open_960', const Size(960, 600))]) { + vaultDirOf = () async => dir; + t.view.physicalSize = size; + t.view.devicePixelRatio = 1; + await t.runAsync(() => t.pumpWidget(RepaintBoundary( + key: key, child: MaterialApp(theme: buildTheme(), debugShowCheckedModeBanner: false, home: VaultPage(c: c))))); + for (var i = 0; i < 10; i++) { + await t.runAsync(() => Future.delayed(const Duration(milliseconds: 100))); + await t.pump(const Duration(milliseconds: 50)); + } + await t.enterText(find.byType(TextField), 'pw'); + await t.tap(find.byType(FilledButton)); + for (var i = 0; i < 30 && find.text('사진.jpg').evaluate().isEmpty; i++) { + await t.runAsync(() => Future.delayed(const Duration(milliseconds: 100))); + await t.pump(const Duration(milliseconds: 50)); + } + await t.pump(const Duration(milliseconds: 300)); + final boundary = key.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final image = await t.runAsync(() => boundary.toImage()); + final bytes = await t.runAsync(() => image!.toByteData(format: ui.ImageByteFormat.png)); + File(p.join(out, 'vault_$name.png')).writeAsBytesSync(bytes!.buffer.asUint8List()); + await t.pumpWidget(const SizedBox()); + } + t.view.reset(); + }); +} diff --git a/integration_test/windows_wifi_shot_test.dart b/integration_test/windows_wifi_shot_test.dart index 78aa826..8817f2e 100644 --- a/integration_test/windows_wifi_shot_test.dart +++ b/integration_test/windows_wifi_shot_test.dart @@ -21,6 +21,8 @@ import 'package:path/path.dart' as p; /// 크기 412 · 960, 언어 ko · en · ja. 실행: flutter test integration_test\windows_wifi_shot_test.dart -d windows void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + // ⑦ 밝은 화면 캡처: JJ_SHOT_THEME=light + if (Platform.environment['JJ_SHOT_THEME'] == 'light') JjColors.use(Brightness.light); final out = Platform.environment['JJ_SHOT_DIR'] ?? p.join(Directory.systemTemp.path, 'jj_shots_wifi'); for (final lang in ['ko', 'en', 'ja']) { for (final (w, h) in [(412, 900), (960, 600)]) {