From 51f16d554864a9d59f31a41a8c4b27dc026d2bc2 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:02:55 +0900 Subject: [PATCH] =?UTF-8?q?=EC=BA=A1=EC=B2=98=20=EC=8B=9C=ED=97=98:=20264?= =?UTF-8?q?=20=C2=B7=20265=20=EC=9D=B4=EB=8F=99=20=EB=92=A4=20(=EC=9B=90?= =?UTF-8?q?=EB=B3=B8=EC=97=90=20=EB=82=A8=EC=9D=8C=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=C2=B7=20=EC=9B=90=EB=B3=B8=20=EB=8B=A4=EC=8B=9C=20=EC=9D=BD?= =?UTF-8?q?=EA=B8=B0)=20=C2=B7=20SFTP=20=ED=82=A4=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=EA=B8=80=205=EA=B0=80=EC=A7=80=20(=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=EC=9E=90=20=EC=A7=80=EC=8B=9C=2010/21)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - windows_move_left_shot_test: 폴더를 합쳐 옮긴 뒤 알림 "n개는 원본에 남음 (건너뜀)" · 펼쳐 둔 원본 D 를 다시 읽어 남은 것만, ko · en · ja × 960 · 1400. 정식 판 후보 1b31609 로 6개 통과 (캡처 release\test\shots_v20261020_001 moveleft_*) - windows_sshkey_shot_test: [개인 키 파일 고르기] 를 가짜 고르기로, 실제 시험 서버 (paramiko) · ssh-keygen 키로 .ppk 안내 · 고른 뒤 안내 · 암호 필요 · 암호 틀림 · 서버 거부를 ko · en · ja × 412 로 (9f6c5c0 의 글, after_1020 으로 3개 통과, 캡처 release\test\shots_f1 sshkey_*). [저장] 은 누르지 않음 (설정 메모리만) Co-Authored-By: Claude Opus 5.5 --- .../windows_move_left_shot_test.dart | 96 ++++++++++++ .../windows_sshkey_shot_test.dart | 138 ++++++++++++++++++ 2 files changed, 234 insertions(+) create mode 100644 integration_test/windows_move_left_shot_test.dart create mode 100644 integration_test/windows_sshkey_shot_test.dart diff --git a/integration_test/windows_move_left_shot_test.dart b/integration_test/windows_move_left_shot_test.dart new file mode 100644 index 0000000..766a463 --- /dev/null +++ b/integration_test/windows_move_left_shot_test.dart @@ -0,0 +1,96 @@ +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/l10n/tr.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/explorer_page.dart'; +import 'package:jj_mkvmaker/ui/theme.dart'; +import 'package:path/path.dart' as p; + +import 'support/temp_dir.dart'; + +/// 264 · 265 (관리자 지시 10/21): 폴더를 합쳐 옮긴 뒤 - 알림에 "n개는 원본에 남음 (건너뜀)" · 펼쳐 둔 원본 폴더를 다시 읽어 +/// 옮겨진 파일은 보이지 않고 남은 것만 보인다. ko · en · ja × 960 · 1400 (두 창). 시험 폴더 안에서만 (실제 이동, 휴지통 쓰지 않음) +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + final out = Platform.environment['JJ_SHOT_DIR'] ?? ''; + + for (final lang in ['ko', 'en', 'ja']) { + for (final (w, h) in [(960, 600), (1400, 800)]) { + testWidgets('264 · 265 이동 뒤 $lang ${w}x$h', (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_moveleft_'); + final left = p.join(tmp.path, 'left'), right = p.join(tmp.path, 'right'); + final d = p.join(left, 'D'); + File(p.join(d, 'same.txt')) + ..createSync(recursive: true) + ..writeAsStringSync('old') + ..setLastModifiedSync(DateTime(2026, 1, 1)); + File(p.join(d, 'add.txt')).writeAsStringSync('a'); + File(p.join(right, 'D', 'same.txt')) + ..createSync(recursive: true) + ..writeAsStringSync('NEWER'); + final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + c.settings + ..explorerPaths = [left, right] + ..copyConflict = 'overwrite'; + final root = p.rootPrefix(tmp.path); + ExplorerPage.debugVolumes = () => [(root, root.replaceAll(RegExp(r'[\\/]+$'), ''))]; + addTearDown(() => ExplorerPage.debugVolumes = null); + final shotKey = GlobalKey(); + await t.pumpWidget(RepaintBoundary( + key: shotKey, + child: MaterialApp(theme: buildTheme(), debugShowCheckedModeBanner: false, home: ExplorerPage(c: c)), + )); + Future settle(bool Function() done, {int rounds = 150}) async { + for (var i = 0; i < rounds && !done(); i++) { + await t.runAsync(() => Future.delayed(const Duration(milliseconds: 40))); + await t.pump(); + } + await t.pump(const Duration(milliseconds: 300)); + } + + Future shot(String name) async { + expect(t.takeException(), isNull, reason: '$name 넘침'); + if (out.isEmpty) return; + final b = shotKey.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final img = await t.runAsync(() => b.toImage()); + final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png)); + Directory(out).createSync(recursive: true); + File(p.join(out, 'moveleft_${name}_${lang}_$w.png')).writeAsBytesSync(bytes!.buffer.asUint8List()); + } + + await settle(() => find.text('D').evaluate().length >= 2); + // 왼쪽 D 를 눌러 펼치고 (고른 것) 안의 add.txt 가 보이게 + await t.tap(find.text('D').first); + await settle(() => find.text('add.txt').evaluate().isNotEmpty); + await shot('1_before'); + await t.tap(find.text(tr('이동')).first); + await settle(() => find.widgetWithText(FilledButton, tr('이동')).evaluate().isNotEmpty); + await t.tap(find.widgetWithText(FilledButton, tr('이동'))); + await settle(() => find.byType(LinearProgressIndicator).evaluate().isEmpty && find.byType(SnackBar).evaluate().isNotEmpty); + await settle(() => false, rounds: 10); // 알림이 다 올라오게 + expect(File(p.join(right, 'D', 'add.txt')).existsSync(), isTrue); + expect(File(p.join(d, 'same.txt')).readAsStringSync(), 'old', reason: '받는 쪽이 더 새것이라 원본에 남음'); + expect(find.textContaining(trf('{0}개는 원본에 남음 (건너뜀)', [1])), findsOneWidget, reason: '264: 알림에'); + expect(find.text('add.txt'), findsNothing, reason: '265: 원본 D 를 다시 읽음'); + expect(find.text('same.txt'), findsOneWidget); + await shot('2_after'); + ScaffoldMessenger.of(t.element(find.byType(ExplorerPage))).clearSnackBars(); + await settle(() => false, rounds: 20); + }); + } + } +} diff --git a/integration_test/windows_sshkey_shot_test.dart b/integration_test/windows_sshkey_shot_test.dart new file mode 100644 index 0000000..72cf803 --- /dev/null +++ b/integration_test/windows_sshkey_shot_test.dart @@ -0,0 +1,138 @@ +import 'dart:async'; +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/sftp.dart'; +import 'package:jj_mkvmaker/l10n/tr.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/sftp_settings.dart'; +import 'package:jj_mkvmaker/ui/theme.dart'; +import 'package:path/path.dart' as p; + +import '../test/support/sftp_server.dart'; +import 'support/temp_dir.dart'; + +/// [개인 키 파일 고르기] 가 고르는 파일을 시험이 정한다 (실제 파일 창은 열지 않음) +class _PickStorage extends DesktopStorageService { + String? next; + @override + Future> pickFiles({required String title, required List extensions, String? initialDirectory}) async => + next == null ? const [] : [next!]; +} + +/// 관리자 지시 (10/21): SFTP 키 로그인의 사용자 글 - 키 고른 뒤 안내 · 암호 필요 · 암호 틀림 · 서버 거부 · .ppk 안내 를 +/// ko · en · ja × 412 로 찍는다. 실제 시험 서버 (paramiko) · ssh-keygen 으로 만든 시험 키만 쓴다 (시험 폴더 안, 설정은 메모리만) +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + final out = Platform.environment['JJ_SHOT_DIR'] ?? ''; + final keygen = r'C:\Windows\System32\OpenSSH\ssh-keygen.exe'; + final skip = !sftpTestServerAvailable || !File(keygen).existsSync(); + + for (final lang in ['ko', 'en', 'ja']) { + testWidgets('SSH 키 글 $lang 412x900', (t) async { + t.view.physicalSize = const Size(412, 900); + 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_sshkeyshot_'); + final root = Directory(p.join(tmp.path, 'root'))..createSync(); + Future gen(String name, String pass) async { + final f = p.join(tmp.path, name); + final r = await Process.run(keygen, ['-q', '-t', 'ed25519', '-N', pass, '-f', f, '-C', 'jj-test']); + expect(r.exitCode, 0, reason: '${r.stderr}'); + return f; + } + + late SftpTestServer server; + late String keyA, keyB; + await t.runAsync(() async { + keyA = await gen('a', 'kp'); + keyB = await gen('b', ''); + server = await SftpTestServer.start(root: root.path, hostKeyFile: p.join(tmp.path, 'hostkey'), password: null, pubkey: '$keyA.pub'); + }); + addTearDown(() => t.runAsync(server.stop)); + final ppk = File(p.join(tmp.path, 'key.ppk')) + ..writeAsStringSync('PuTTY-User-Key-File-3: ssh-ed25519\nEncryption: none\nComment: x\nPublic-Lines: 1\nAAAA\n'); + final storage = _PickStorage(); + final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: storage)); + final old = SftpServer(id: 's1', name: 'NAS', host: '127.0.0.1', port: server.port, user: 'jj', hostKeyFingerprint: server.fingerprint); + final navKey = GlobalKey(); + final shotKey = GlobalKey(); + await t.pumpWidget(RepaintBoundary( + key: shotKey, + child: MaterialApp( + theme: buildTheme(), debugShowCheckedModeBanner: false, navigatorKey: navKey, home: const Scaffold(body: SizedBox.expand())), + )); + Future settle(bool Function() done, {int rounds = 150}) async { + for (var i = 0; i < rounds && !done(); i++) { + await t.runAsync(() => Future.delayed(const Duration(milliseconds: 40))); + await t.pump(); + } + await t.pump(const Duration(milliseconds: 200)); + } + + Future shot(String name, String expectText) async { + final f = find.textContaining(expectText); + await settle(() => f.evaluate().isNotEmpty); + expect(f, findsOneWidget, reason: name); + await t.ensureVisible(f); + await settle(() => false, rounds: 5); + expect(t.takeException(), isNull, reason: '$name 넘침'); + if (out.isEmpty) return; + final b = shotKey.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final img = await t.runAsync(() => b.toImage()); + final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png)); + Directory(out).createSync(recursive: true); + File(p.join(out, 'sshkey_${name}_${lang}_412.png')).writeAsBytesSync(bytes!.buffer.asUint8List()); + } + + Future pick(String file) async { + storage.next = file; + final b = find.byIcon(Icons.key_outlined); + await t.ensureVisible(b); + await t.tap(b); + } + + Future testConnection() async { + final b = find.text(tr('연결 확인')); + await t.ensureVisible(b); + await t.tap(b); + await settle(() => find.byType(LinearProgressIndicator).evaluate().isNotEmpty, rounds: 20); + await settle(() => find.byType(LinearProgressIndicator).evaluate().isEmpty, rounds: 400); + } + + unawaited(editSftpServer(navKey.currentContext!, c, old: old)); + await settle(() => find.text(tr('연결 확인')).evaluate().isNotEmpty); + // ① PuTTY 키를 고르면 바꾸는 법 + await pick(ppk.path); + await shot('1_ppk', 'Export OpenSSH key'); + // ② 암호가 걸린 키를 고르면 "아래에 키 암호를" + await pick(keyA); + await shot('2_picked', tr('개인 키를 넣었습니다. 암호가 걸린 키라 아래에 키 암호를 넣으세요 (원래 파일 경로는 저장하지 않습니다)')); + // ③ 키 암호 없이 [연결 확인] + await testConnection(); + await shot('3_needpass', tr('개인 키에 암호가 걸려 있습니다. 키 암호를 넣으세요')); + // ④ 틀린 키 암호 + await t.enterText(find.widgetWithText(TextField, tr('개인 키 암호')), 'nope'); + await testConnection(); + await shot('4_badpass', tr('개인 키의 암호가 맞지 않습니다')); + // ⑤ 서버가 모르는 키 (짝 공개 키가 서버에 없음) + await pick(keyB); + await settle(() => false, rounds: 5); + await testConnection(); + await shot('5_rejected', 'authorized_keys'); + navKey.currentState!.popUntil((r) => r.isFirst); + await settle(() => false, rounds: 10); + expect(c.settings.sftpServers, isEmpty, reason: '[저장] 은 누르지 않음'); + }, skip: skip, timeout: const Timeout(Duration(minutes: 3))); + } +}