캡처 시험: 264 · 265 이동 뒤 (원본에 남음 알림 · 원본 다시 읽기) · SFTP 키 로그인 글 5가지 (관리자 지시 10/21)

- 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 <noreply@anthropic.com>
rc2
RyuJongJin 2026-10-21 11:02:55 +09:00
parent 9f6c5c0bd5
commit 51f16d5548
2 changed files with 234 additions and 0 deletions

View File

@ -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<void> settle(bool Function() done, {int rounds = 150}) async {
for (var i = 0; i < rounds && !done(); i++) {
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 40)));
await t.pump();
}
await t.pump(const Duration(milliseconds: 300));
}
Future<void> 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);
});
}
}
}

View File

@ -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<List<String>> pickFiles({required String title, required List<String> 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<String> 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<NavigatorState>();
final shotKey = GlobalKey();
await t.pumpWidget(RepaintBoundary(
key: shotKey,
child: MaterialApp(
theme: buildTheme(), debugShowCheckedModeBanner: false, navigatorKey: navKey, home: const Scaffold(body: SizedBox.expand())),
));
Future<void> settle(bool Function() done, {int rounds = 150}) async {
for (var i = 0; i < rounds && !done(); i++) {
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 40)));
await t.pump();
}
await t.pump(const Duration(milliseconds: 200));
}
Future<void> 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<void> pick(String file) async {
storage.next = file;
final b = find.byIcon(Icons.key_outlined);
await t.ensureVisible(b);
await t.tap(b);
}
Future<void> 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)));
}
}