WIP 시험: 묶음6 화면 캡처 (Windows 통합 시험, 412 · 960 × ko · en · ja) + 85 작은 표시를 TransferProgressChip 으로 (검토 대기)

- 관리자 지시 5: 기기 없이 15 가 볼 수 있게 integration_test/windows_b6_shot_test.dart (JJ_SHOT_DIR 에 PNG):
  78 이름 일괄 창 (겹침 빨간 줄) · 79 찾기 결과 (고른 줄 · 아래 할 일 줄) · 81 빠른 보기 (자막 글) · 82 놓기 묻기 창 ·
  85 작은 표시 (보통 · 멈춤) · 86 풀기 확인 창 (밖으로 나가는 경로 · 링크 · 쓸 수 없는 이름 · 덮어쓰기 경고) · 87 분석 (큰 폴더 · 큰 파일).
  시험 파일은 임시 폴더에만 (끝나면 지움), 설정은 메모리에만.
- 85 의 작은 표시를 공개 위젯 TransferProgressChip 으로 뺐다 (모양 · 동작 같음) - 멈춤 상태를 실제 실패 없이 캡처하려고.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
묶음6
RyuJongJin 2026-10-17 15:19:46 +09:00
parent e0466d6168
commit 3b6d50f409
2 changed files with 229 additions and 32 deletions

View File

@ -0,0 +1,180 @@
import 'dart:io';
import 'dart:ui' as ui;
import 'package:archive/archive_io.dart';
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/file_ops.dart';
import 'package:jj_mkvmaker/core/models.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/archive_ui.dart';
import 'package:jj_mkvmaker/ui/batch_rename_dialog.dart';
import 'package:jj_mkvmaker/ui/explorer_drag.dart';
import 'package:jj_mkvmaker/ui/explorer_page.dart' show TransferProgressChip;
import 'package:jj_mkvmaker/ui/explorer_search.dart';
import 'package:jj_mkvmaker/ui/quick_look.dart';
import 'package:jj_mkvmaker/ui/storage_page.dart';
import 'package:jj_mkvmaker/ui/theme.dart';
import 'package:path/path.dart' as p;
/// 관리자 지시 5 (10/17): 기기 없이 15 가 볼 수 있게 묶음6 새 화면을 PNG 로 (JJ_SHOT_DIR, 없으면 임시 폴더).
/// 78 이름 일괄 창 · 79 찾기 결과 · 81 빠른 보기 · 82 놓기 묻기 창 · 85 작은 표시 (보통 · 멈춤) · 86 풀기 확인 창 (경고 줄) · 87 분석 화면.
/// 크기 412 (폰 세로) · 960, 언어 ko · en · ja. 시험 파일은 임시 폴더에만 (끝나면 지움), 설정은 메모리에만.
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
final out = Platform.environment['JJ_SHOT_DIR'] ?? p.join(Directory.systemTemp.path, 'jj_shots_b6');
for (final lang in ['ko', 'en', 'ja']) {
for (final (w, h) in [(412, 900), (960, 600)]) {
testWidgets('묶음6 화면 $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 = Directory.systemTemp.createTempSync('jj_b6shot_');
addTearDown(() => tmp.deleteSync(recursive: true));
// 시험 파일
File(p.join(tmp.path, 'Show', 'ep01.mkv'))
..createSync(recursive: true)
..writeAsBytesSync(List.filled(4000, 0));
File(p.join(tmp.path, 'Show', 'ep02.mkv')).writeAsBytesSync(List.filled(3000, 0));
File(p.join(tmp.path, 'Show', 'Show 01.mkv')).writeAsBytesSync(List.filled(10, 0));
File(p.join(tmp.path, 'Show', 'ep01.srt')).writeAsStringSync('1\n00:00:01,000 --> 00:00:02,000\n안녕 hello\n');
File(p.join(tmp.path, 'docs', 'note.txt'))
..createSync(recursive: true)
..writeAsStringSync('메모 note');
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
final navKey = GlobalKey<NavigatorState>();
final shotKey = GlobalKey();
await t.pumpWidget(RepaintBoundary(
key: shotKey,
child: MaterialApp(theme: buildTheme(), navigatorKey: navKey, home: const Scaffold(body: SizedBox.expand())),
));
BuildContext ctx() => navKey.currentContext!;
Future<void> settle([int n = 8]) async {
for (var i = 0; i < n; i++) {
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 60)));
await t.pump();
}
}
Future<void> shot(String name) async {
await settle(3);
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, 'b6_${name}_${lang}_$w.png')).writeAsBytesSync(bytes!.buffer.asUint8List());
}
Future<void> closeAll() async {
navKey.currentState!.popUntil((r) => r.isFirst);
await settle(4);
}
// 78 이름 일괄 바꾸기 - 틀 "Show [C]" (첫 번째가 고르지 않은 "Show 01.mkv" 와 겹쳐 빨갛게)
final eps = [p.join(tmp.path, 'Show', 'ep01.mkv'), p.join(tmp.path, 'Show', 'ep02.mkv')];
showBatchRename(ctx(), eps, isDir: (_) => false);
await settle();
await t.enterText(find.byType(TextField).first, 'Show [C]');
await settle();
await shot('78_rename');
await closeAll();
// 79 찾기 결과 - 하나 골라 아래 할 일 줄까지
navKey.currentState!.push(MaterialPageRoute<void>(
builder: (_) => SearchResultsPage(
root: tmp.path,
cond: const SearchConditions(name: 'ep'),
showHidden: false,
otherDir: p.join(tmp.path, 'docs'),
onOpen: (_) async {},
),
));
await settle(12);
await t.tap(find.byType(Checkbox).first);
await settle();
await shot('79_search');
await closeAll();
// 81 빠른 보기 - 자막 (글 미리 보기)
final list = await t.runAsync(() => listEntries(p.join(tmp.path, 'Show')));
final srt = list!.indexWhere((e) => e.name == 'ep01.srt');
showDialog<void>(
context: ctx(),
builder: (_) => QuickLook(
entries: list,
index: srt,
probe: (_) async => const MediaInfo(streams: []),
thumbnail: (_) async => null,
imageExts: const ['jpg', 'png'],
onOpen: (_) {},
),
);
await settle(10);
await shot('81_quicklook');
await closeAll();
// 82 놓기 묻기 창
askDropAction(ctx(), eps, p.join(tmp.path, 'docs'));
await settle();
await shot('82_drop_ask');
await closeAll();
// 85 작은 표시 - 보통 · 멈춤
for (final (name, failed) in [('85_chip', false), ('85_chip_failed', true)]) {
navKey.currentState!.push(MaterialPageRoute<void>(
builder: (_) => Scaffold(
body: Align(
alignment: Alignment.bottomRight,
child: Padding(
padding: const EdgeInsets.all(8),
child: TransferProgressChip(running: failed ? 0 : 2, percent: failed ? null : 45, failed: failed, onTap: () {}),
),
),
),
));
await settle();
await shot(name);
await closeAll();
}
// 86 풀기 확인 창 - 밖으로 나가는 경로 · 링크 · 쓸 수 없는 이름 · 덮어쓰기 경고
final a = Archive()
..addFile(ArchiveFile.string('ok/a.txt', 'a'))
..addFile(ArchiveFile.string('../evil.txt', 'x'))
..addFile(ArchiveFile.string('CON.txt', 'x'))
..addFile(ArchiveFile.string('lnk', '../x')..mode = 0xA1FF);
final zip = p.join(tmp.path, 'sample.zip');
File(zip).writeAsBytesSync(ZipEncoder().encode(a));
extractArchiveFlow(ctx(), c, zip);
await settle(15);
await shot('86_extract_confirm');
// 같은 이름: 덮어쓰기 → "되돌릴 수 없습니다"
await t.tap(find.byType(DropdownButton<NameConflict>));
await settle();
await t.tap(find.text(lang == 'ko' ? '덮어쓰기' : (lang == 'en' ? 'Overwrite' : '上書き')).last);
await settle();
await shot('86_extract_overwrite');
await closeAll();
// 87 분석 화면 - 큰 폴더 · 큰 파일
navKey.currentState!.push(MaterialPageRoute<void>(builder: (_) => StorageAnalysisPage(root: tmp.path)));
await settle(15);
await shot('87_storage_folders');
await t.tap(find.byType(ChoiceChip).last);
await settle();
await shot('87_storage_files');
await closeAll();
});
}
}
}

View File

@ -2717,38 +2717,11 @@ class _ExplorerPageState extends State<ExplorerPage> {
final pct = run.isEmpty || run.any((j) => j.counting) final pct = run.isEmpty || run.any((j) => j.counting)
? null ? null
: (run.fold<double>(0, (a, j) => a + j.overall) / run.length * 100).round(); : (run.fold<double>(0, (a, j) => a + j.overall) / run.length * 100).round();
return Material( return TransferProgressChip(
elevation: 6, running: run.length,
color: failed ? JjColors.danger.withValues(alpha: 0.25) : JjColors.panelHigh, percent: pct,
borderRadius: BorderRadius.circular(18), failed: failed,
child: InkWell( onTap: () => c.updateSettings((x) => x.explorerProgressHidden = false));
borderRadius: BorderRadius.circular(18),
onTap: () => c.updateSettings((x) => x.explorerProgressHidden = false),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(mainAxisSize: MainAxisSize.min, children: [
SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2, value: run.isEmpty ? 1 : (pct == null ? null : pct / 100)),
),
const SizedBox(width: 8),
Text(
failed
? tr('복사 · 이동 멈춤 - 눌러서 보기')
: run.isEmpty
? tr('복사 · 이동 끝')
: pct == null
? trf('복사 · 이동 {0}개', [run.length])
: trf('복사 · 이동 {0}개 · {1}%', [run.length, pct]),
style: const TextStyle(fontSize: 12.5),
),
const SizedBox(width: 4),
Tooltip(message: tr('진행 패널 보기'), child: const Icon(Icons.expand_less, size: 18)),
]),
),
),
);
}, },
), ),
), ),
@ -4746,6 +4719,50 @@ class _Thumbs {
} }
} }
/// 85: 진행 패널을 숨겼을 때 오른쪽 아래 작은 표시 "복사 · 이동 n개 · 45%" (멈추면 빨갛게). 누르면 다시 펼침
class TransferProgressChip extends StatelessWidget {
/// 도는 것 수 (0 이면 끝) · 진행률 (모르면 null) · 멈춘 (실패한) 것이 있는지
final int running;
final int? percent;
final bool failed;
final VoidCallback onTap;
const TransferProgressChip({super.key, required this.running, this.percent, this.failed = false, required this.onTap});
@override
Widget build(BuildContext context) => Material(
elevation: 6,
color: failed ? JjColors.danger.withValues(alpha: 0.25) : JjColors.panelHigh,
borderRadius: BorderRadius.circular(18),
child: InkWell(
borderRadius: BorderRadius.circular(18),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(mainAxisSize: MainAxisSize.min, children: [
SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2, value: running == 0 ? 1 : (percent == null ? null : percent! / 100)),
),
const SizedBox(width: 8),
Text(
failed
? tr('복사 · 이동 멈춤 - 눌러서 보기')
: running == 0
? tr('복사 · 이동 끝')
: percent == null
? trf('복사 · 이동 {0}개', [running])
: trf('복사 · 이동 {0}개 · {1}%', [running, percent]),
style: const TextStyle(fontSize: 12.5),
),
const SizedBox(width: 4),
Tooltip(message: tr('진행 패널 보기'), child: const Icon(Icons.expand_less, size: 18)),
]),
),
),
);
}
/// 81: 빠른 보기의 큰 썸네일 (가로 640, 같은 jj_thumbs 에 따로 저장). 사용자가 연 것이라 작업 중에도 만든다 /// 81: 빠른 보기의 큰 썸네일 (가로 640, 같은 jj_thumbs 에 따로 저장). 사용자가 연 것이라 작업 중에도 만든다
class _BigThumbs { class _BigThumbs {
static final _cache = <String, Future<File?>>{}; static final _cache = <String, Future<File?>>{};