diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 2f86e6b..ebd5d73 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -106,6 +106,7 @@ "AI 해상도 올리기 (모든 장)": "AI upscaling (all pages)", "AI 해상도 올리기 (이 장)": "AI upscaling (this page)", "AI 해상도 올리기 등 오래 걸린 작업이 끝났을 때": "When a long task such as AI upscaling finishes", + "AI 해상도 올리기: {0}": "AI upscaling: {0}", "API 키": "API key", "API 키 (선택)": "API key (optional)", "API 키 (필수, 무료)": "API key (required, free)", @@ -327,6 +328,7 @@ "{0} 에 연결할 수 없습니다. 네트워크 · NAS 가 켜져 있는지 확인하세요.": "Cannot connect to {0}. Check that the network and NAS are on.", "{0} 에서 그리는 중 ({1}초)": "Drawing on {0} ({1} s)", "{0} 에서 받을 파일을 찾지 못했습니다": "Could not find a file to download from {0}", + "{0} 올림 - [보기] 로 비교하고 저장": "{0} upscaled - compare and save with [View]", "{0} 외 {1}개": "{0} and {1} more", "{0} 은(는) 이미 이 폴더에 있어 옮길 것이 없습니다.\n{1}": "{0} is already in this folder, so there is nothing to move.\n{1}", "{0} 을 담았습니다. 넣을 폴더에서 [붙여넣기] 를 누르세요 ({1}).": "Picked up {0}. Open the target folder and tap [Paste] ({1}).", @@ -746,6 +748,7 @@ "두 창으로 보기 (좌우)": "Show two panes (side by side)", "뒤로": "Back", "뒤로 보내고 계속": "Keep running in background", + "뒤에서 계속": "Continue in background", "드라이브의 맨 위 폴더는 지울 수 없습니다": "The top folder of a drive can't be deleted", "등록": "Register", "등록 중 일부가 실패했습니다.": "Some registrations failed.", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 9929ca3..1cbcbd5 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -106,6 +106,7 @@ "AI 해상도 올리기 (모든 장)": "AI 高解像度化 (全ページ)", "AI 해상도 올리기 (이 장)": "AI 高解像度化 (このページ)", "AI 해상도 올리기 등 오래 걸린 작업이 끝났을 때": "AI 高解像度化など時間のかかる作業が終わったとき", + "AI 해상도 올리기: {0}": "AI 高解像度化: {0}", "API 키": "API キー", "API 키 (선택)": "API キー (任意)", "API 키 (필수, 무료)": "API キー(必須・無料)", @@ -327,6 +328,7 @@ "{0} 에 연결할 수 없습니다. 네트워크 · NAS 가 켜져 있는지 확인하세요.": "{0} に接続できません。ネットワーク・NAS がオンか確認してください。", "{0} 에서 그리는 중 ({1}초)": "{0} で描画中 ({1} 秒)", "{0} 에서 받을 파일을 찾지 못했습니다": "{0} からダウンロードするファイルが見つかりませんでした", + "{0} 올림 - [보기] 로 비교하고 저장": "{0} を高解像度化 - [表示] で比較して保存", "{0} 외 {1}개": "{0} ほか {1} 件", "{0} 은(는) 이미 이 폴더에 있어 옮길 것이 없습니다.\n{1}": "{0} は既にこのフォルダーにあるため移動するものがありません。\n{1}", "{0} 을 담았습니다. 넣을 폴더에서 [붙여넣기] 를 누르세요 ({1}).": "{0} を保持しました。入れるフォルダーで [貼り付け] を押してください ({1})。", @@ -746,6 +748,7 @@ "두 창으로 보기 (좌우)": "2 画面で表示 (左右)", "뒤로": "戻る", "뒤로 보내고 계속": "裏で続ける", + "뒤에서 계속": "バックグラウンドで続ける", "드라이브의 맨 위 폴더는 지울 수 없습니다": "ドライブの最上位フォルダーは削除できません", "등록": "登録", "등록 중 일부가 실패했습니다.": "登録の一部に失敗しました。", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 133a98f..541409a 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -106,6 +106,7 @@ "AI 해상도 올리기 (모든 장)": "AI 提高分辨率 (全部页面)", "AI 해상도 올리기 (이 장)": "AI 提高分辨率 (本页)", "AI 해상도 올리기 등 오래 걸린 작업이 끝났을 때": "AI 提升分辨率等耗时任务完成时", + "AI 해상도 올리기: {0}": "AI 提升分辨率: {0}", "API 키": "API 密钥", "API 키 (선택)": "API 密钥 (可选)", "API 키 (필수, 무료)": "API 密钥(必填,免费)", @@ -327,6 +328,7 @@ "{0} 에 연결할 수 없습니다. 네트워크 · NAS 가 켜져 있는지 확인하세요.": "无法连接到 {0}。请确认网络和 NAS 已开启。", "{0} 에서 그리는 중 ({1}초)": "正在 {0} 上绘制 ({1} 秒)", "{0} 에서 받을 파일을 찾지 못했습니다": "未在 {0} 找到要下载的文件", + "{0} 올림 - [보기] 로 비교하고 저장": "{0} 已提升 - 用 [查看] 比较并保存", "{0} 외 {1}개": "{0} 等 {1} 个", "{0} 은(는) 이미 이 폴더에 있어 옮길 것이 없습니다.\n{1}": "{0} 已在此文件夹中,无需移动。\n{1}", "{0} 을 담았습니다. 넣을 폴더에서 [붙여넣기] 를 누르세요 ({1}).": "已暂存 {0}。请在目标文件夹中点按 [粘贴] ({1})。", @@ -746,6 +748,7 @@ "두 창으로 보기 (좌우)": "以双窗格显示 (左右)", "뒤로": "后退", "뒤로 보내고 계속": "转到后台继续", + "뒤에서 계속": "在后台继续", "드라이브의 맨 위 폴더는 지울 수 없습니다": "无法删除驱动器的根文件夹", "등록": "注册", "등록 중 일부가 실패했습니다.": "部分注册失败。", diff --git a/lib/app/ai_local.dart b/lib/app/ai_local.dart index 77f433e..40e8e00 100644 --- a/lib/app/ai_local.dart +++ b/lib/app/ai_local.dart @@ -558,12 +558,35 @@ class AiJobs extends ChangeNotifier { String? lastDone; String? lastDoneDir; - void done(String text, {String? dir}) { + /// 176: 끝난 일을 다시 볼 것 (화면을 떠나 끝난 한 장 해상도 올리기 → 비교 · 저장). 작업 현황의 [보기] + Future Function()? lastDoneView; + void Function()? _lastDoneDispose; + + /// [dispose]: 다음 일을 시작하거나 다른 끝난 일로 바뀔 때 (남겨 둔 임시 결과 지우기 등) + void done(String text, {String? dir, Future Function()? view, void Function()? dispose}) { + _clearDone(); lastDone = text; lastDoneDir = dir; + lastDoneView = view; + _lastDoneDispose = dispose; notifyListeners(); } + /// [보기] 를 한 번 쓰면 (비교 화면에서 저장 · 버림) 지운다 + void clearDoneView() { + lastDoneView = null; + _lastDoneDispose = null; + notifyListeners(); + } + + void _clearDone() { + _lastDoneDispose?.call(); + _lastDoneDispose = null; + lastDoneView = null; + lastDone = null; + lastDoneDir = null; + } + void start(String t, ImageAiEngine engine, int count) { title = t; _engine = engine; @@ -571,8 +594,7 @@ class AiJobs extends ChangeNotifier { remaining = count; progress = 0; eta = null; - lastDone = null; - lastDoneDir = null; + _clearDone(); notifyListeners(); } @@ -609,8 +631,7 @@ class AiJobs extends ChangeNotifier { {required void Function() onCancel, Set uses = const {}}) async { if (busy) throw ImageAiException(tr('이미 작업 중입니다')); inUse = uses; - lastDone = null; - lastDoneDir = null; + _clearDone(); var stop = false; this.title = title; total = count; diff --git a/lib/app/ai_upscale.dart b/lib/app/ai_upscale.dart index 26f9a44..c06692d 100644 --- a/lib/app/ai_upscale.dart +++ b/lib/app/ai_upscale.dart @@ -71,7 +71,29 @@ Uint8List _finish((Uint8List, int, int, int, String, int) a) { /// 그 밖 (Windows · GPU) 은 엔진 기본값 bool _androidCpu(SdDevice d) => Platform.isAndroid && d.backend == 'cpu'; int upscaleThreads(SdDevice d) => _androidCpu(d) ? Platform.numberOfProcessors : 0; -int upscaleTileSize(SdDevice d) => _androidCpu(d) ? 512 : 0; +int upscaleTileSize(SdDevice d, {int? memTotalMb, int? longSide}) => + _androidCpu(d) ? androidUpscaleTile(memTotalMb: memTotalMb, longSide: longSide) : 0; + +/// 176: Android CPU 타일 크기 - 메모리는 타일 넓이에 비례한다 (S10 실측: 타일 512 에서 엔진 6.6GB, 기기의 45%). +/// 기기 전체 메모리의 20% 안에 드는 가장 큰 타일 (512 · 384 · 320 · 256 · 192). 메모리를 모르면 큰 그림 (긴 변 1000 이상) 은 256. +int androidUpscaleTile({int? memTotalMb, int? longSide}) { + const at512Mb = 6600; + if (memTotalMb == null || memTotalMb <= 0) return (longSide ?? 0) >= 1000 ? 256 : 512; + for (final t in const [512, 384, 320, 256]) { + if (at512Mb * (t / 512) * (t / 512) <= memTotalMb * 0.20) return t; + } + return 192; +} + +/// 기기 전체 메모리 (MB, /proc/meminfo - Android · Linux). 모르면 null +int? deviceMemTotalMb() { + try { + final m = RegExp(r'MemTotal:\s+(\d+)\s*kB').firstMatch(File('/proc/meminfo').readAsStringSync()); + return m == null ? null : int.parse(m[1]!) ~/ 1024; + } catch (_) { + return null; + } +} /// 156-2: 걸린 시간을 기억할 열쇠 (처리 장치 · 모델이 같아야 비교가 된다) String upscaleSpeedKey(AiUpscaler up) => '${up.device.backend}|${up.modelId}'; @@ -136,7 +158,15 @@ class AiUpscaler { final tmp = await Directory.systemTemp.createTemp('jj_up_'); final out = p.join(tmp.path, 'up.png'); try { - await runner(device.exe, sdUpscaleArgs(model: modelPath, input: input, out: out, backend: device.backend, threads: upscaleThreads(device), tileSize: upscaleTileSize(device)), + // 176: 큰 그림 · 메모리가 적은 기기는 작은 타일 (메모리를 덜 쓴다) + int? longSide; + try { + final bytes = await File(input).openRead(0, 1 << 16).fold>([], (a, b) => a..addAll(b)); + final info = img.findDecoderForData(Uint8List.fromList(bytes))?.startDecode(Uint8List.fromList(bytes)); + if (info != null) longSide = info.width > info.height ? info.width : info.height; + } catch (_) {} + final tile = upscaleTileSize(device, memTotalMb: Platform.isAndroid ? deviceMemTotalMb() : null, longSide: longSide); + await runner(device.exe, sdUpscaleArgs(model: modelPath, input: input, out: out, backend: device.backend, threads: upscaleThreads(device), tileSize: tile), onProgress: (prog) { final (v, left) = upscaleProgressOf(prog); onProgress?.call(v); diff --git a/lib/ui/ai_upscale_ui.dart b/lib/ui/ai_upscale_ui.dart index d96c6cb..66ca13b 100644 --- a/lib/ui/ai_upscale_ui.dart +++ b/lib/ui/ai_upscale_ui.dart @@ -54,7 +54,12 @@ Future<(String input, String nameFrom, String outDir)?> readerPageFile(AppContro } /// 준비: 모델 · 엔진이 없으면 그 자리에서 무엇을 받을지 고르고 받는다 (156 - 설정 화면으로 보내지 않음). 못 하면 null +/// 시험용: 받기 · 엔진 고르기 없이 쓸 업스케일러 (가짜 엔진) +@visibleForTesting +AiUpscaler Function()? debugUpscalerForTest; + Future _prepare(BuildContext context, AppController c) async { + if (debugUpscalerForTest case final f?) return f(); final store = AiStore.instance ??= AiStore(Directory.systemTemp.path); Future ready() => prepareUpscaler(store, modelSetting: c.settings.aiUpModel, device: c.settings.aiDevice, autoDevice: c.settings.aiAutoDevice); @@ -172,6 +177,11 @@ Future _estimateText(AppController c, AiUpscaler up, String input) asyn /// 121: 한 장 올리기 → 진행 (취소) → [원본 / 올린 것] 비교 → [저장] (새 파일, 원본을 덮지 않음). 저장한 경로 (안 하면 null) Future upscaleOne(BuildContext context, AppController c, {required String input, required String nameFrom, required String outDir}) async { + // 176: 한 장도 작업 (AiJobs) 으로 - 하나씩만 + if (AiJobs.instance.busy) { + ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(content: Text(tr('이미 작업 중입니다')))); + return null; + } final up = await _prepare(context, c); if (up == null || !context.mounted) return null; final progress = ValueNotifier(null); @@ -179,6 +189,9 @@ Future upscaleOne(BuildContext context, AppController c, {required Stri final left = ValueNotifier(null); var cancelled = false; var toPhoto = false; + // 176: [뒤에서 계속] - 진행 창만 닫고 작업은 계속 (작업 알림 · 작업 현황에서 보이고, 끝나면 [보기]) + var background = false; + var dialogOpen = true; // 관리자: CPU 뿐이라 자동이 만화용을 고르면 사진은 질감이 뭉개질 수 있다 - 알리고 바로 바꿀 수 있게 final cpuAnime = c.settings.aiUpModel == 'auto' && up.device.backend == 'cpu' && up.modelId == 'esrgan-anime6b'; final estimate = await _estimateText(c, up, input); @@ -234,42 +247,89 @@ Future upscaleOne(BuildContext context, AppController c, {required Stri }, child: Text(tr('취소')), ), + TextButton( + onPressed: () { + background = true; + Navigator.pop(ctx); + }, + child: Text(tr('뒤에서 계속')), + ), ], ), - )); + ).whenComplete(() => dialogOpen = false)); String? four; Object? error; final watch = Stopwatch()..start(); + final nameShort = p.basename(nameFrom); try { - four = await up.upscale4x(input, onProgress: (v) => progress.value = v, onEta: (d) => left.value = d); - await _recordSpeed(c, up, input, watch.elapsed); + // 176: 작업 (AiJobs) 으로 - 작업 알림 · 앞 서비스 (Android) · 작업 현황에 나오고, 화면을 떠나도 계속한다 + await AiJobs.instance.runTask(trf('AI 해상도 올리기: {0}', [nameShort]), 1, (_, prog) async { + four = await up.upscale4x(input, onProgress: (v) { + progress.value = v; + prog(v); + }, onEta: (d) => left.value = d); + }, onCancel: () { + cancelled = true; + up.cancel(); + }, uses: const {'esrgan-x4plus', 'esrgan-anime6b', 'engine-vulkan', 'engine-cuda', 'engine-cudart'}); + if (four != null) await _recordSpeed(c, up, input, watch.elapsed); } catch (e) { error = e; } - nav.pop(); // 진행 창 - if (!context.mounted) return null; - if (toPhoto) { + if (dialogOpen) nav.pop(); // 진행 창 + if (toPhoto && context.mounted) { await c.updateSettings((x) => x.aiUpModel = 'photo'); if (!context.mounted) return null; return upscaleOne(context, c, input: input, nameFrom: nameFrom, outDir: outDir); } - if (four == null) { - if (!cancelled) ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(content: Text('$error'))); + final result = four; + if (result == null) { + final text = cancelled ? null : '$error'; + if (text == null) return null; + if (!background && context.mounted) { + ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(content: Text(text))); + } else { + AiJobs.instance.done(trf('{0}: {1}', [nameShort, text])); + } return null; } + // 176: 화면에 남아 있으면 바로 비교, 떠났으면 (뒤에서 계속 · 화면 닫음) 결과를 남겨 두고 [보기] + if (!background && context.mounted) return _compareAndSave(Navigator.of(context), c, result, input: input, nameFrom: nameFrom, outDir: outDir); + final text = trf('{0} 올림 - [보기] 로 비교하고 저장', [nameShort]); + AiJobs.instance.done(text, + view: () async { + AiJobs.instance.clearDoneView(); + await _compareAndSave(nav, c, result, input: input, nameFrom: nameFrom, outDir: outDir); + }, + dispose: () { + try { + Directory(p.dirname(result)).deleteSync(recursive: true); + } catch (_) {} + }); + c.note('${tr('AI 해상도 올리기')}: $text'); + if (Platform.isAndroid) { + try { + await const MethodChannel('jj_mkvmaker/android').invokeMethod('notifyDone', {'title': tr('AI 해상도 올리기'), 'text': text}); + } catch (_) {} + } + return null; +} + +/// 4배 결과 [four] 를 [원본 / 올린 것] 비교 → [저장] (새 파일, 원본을 덮지 않음). 끝나면 임시 결과를 지운다 +Future _compareAndSave(NavigatorState nav, AppController c, String four, + {required String input, required String nameFrom, required String outDir}) async { + final messenger = ScaffoldMessenger.maybeOf(nav.context); try { - final save = await Navigator.of(context).push(MaterialPageRoute( + final save = await nav.push(MaterialPageRoute( fullscreenDialog: true, - builder: (_) => UpscaleCompareView(original: input, upscaled: four!, title: p.basename(nameFrom)), + builder: (_) => UpscaleCompareView(original: input, upscaled: four, title: p.basename(nameFrom)), )); - if (save != true || !context.mounted) return null; + if (save != true) return null; final saved = await saveUpscaled(four, source: nameFrom, original: input, o: upscaleOptionsOf(c, outDir: outDir)); - if (context.mounted) { - ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(content: Text(trf('저장했습니다: {0}', [saved])))); - } + messenger?.showSnackBar(SnackBar(content: Text(trf('저장했습니다: {0}', [saved])))); return saved; } catch (e) { - if (context.mounted) ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(content: Text(trf('저장하지 못했습니다: {0}', [e])))); + messenger?.showSnackBar(SnackBar(content: Text(trf('저장하지 못했습니다: {0}', [e])))); return null; } finally { try { diff --git a/lib/ui/work_panel.dart b/lib/ui/work_panel.dart index b4991f6..2b83a88 100644 --- a/lib/ui/work_panel.dart +++ b/lib/ui/work_panel.dart @@ -196,6 +196,8 @@ class WorkPanel extends StatelessWidget { if (!AiJobs.instance.busy && AiJobs.instance.lastDone != null) Row(children: [ Expanded(child: Text('✓ ${AiJobs.instance.lastDone}', style: const TextStyle(fontSize: 12))), + // 176: 화면을 떠나 끝난 한 장 해상도 올리기 - 비교해 보고 저장 + if (AiJobs.instance.lastDoneView case final view?) TextButton(onPressed: view, child: Text(tr('보기'))), if (AiJobs.instance.lastDoneDir case final dir?) TextButton(onPressed: () => openFolderInApp(Navigator.of(context), c, dir), child: Text(tr('열기'))), ]), diff --git a/test/upscale_job_test.dart b/test/upscale_job_test.dart new file mode 100644 index 0000000..cc07328 --- /dev/null +++ b/test/upscale_job_test.dart @@ -0,0 +1,108 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:image/image.dart' as img; +import 'package:jj_mkvmaker/app/ai_local.dart'; +import 'package:jj_mkvmaker/app/ai_upscale.dart'; +import 'package:jj_mkvmaker/app/app_controller.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/ai_upscale_ui.dart'; +import 'package:path/path.dart' as p; + +/// 176: 한 장 해상도 올리기도 작업 (AiJobs) 으로 - 작업 알림 · 앞 서비스 · 작업 현황에 나오고 화면을 떠나도 끝난다 +void main() { + late Directory tmp; + late String input; + late Completer engine; + setUp(() { + tmp = Directory.systemTemp.createTempSync('jj_up_job_'); + input = p.join(tmp.path, 'page.png'); + File(input).writeAsBytesSync(img.encodePng(img.Image(width: 8, height: 6))); + engine = Completer(); + debugUpscalerForTest = () => AiUpscaler( + device: const SdDevice('sd-cli', 'cpu', 'CPU'), + modelPath: 'm.pth', + modelId: 'esrgan-x4plus', + runner: (exe, args, {onProgress, onStart}) async { + await engine.future; // 엔진이 도는 동안 + File(args[args.indexOf('-o') + 1]).writeAsBytesSync(img.encodePng(img.Image(width: 32, height: 24))); + return const []; + }, + ); + }); + tearDown(() { + debugUpscalerForTest = null; + AiJobs.instance.done(''); // 남은 [보기] 정리 + tmp.deleteSync(recursive: true); + }); + + test('타일 크기: 기기 메모리의 20% 안 (S10 16GB → 320), 메모리를 모르면 큰 그림만 256', () { + expect(androidUpscaleTile(memTotalMb: 15000), 320); + expect(androidUpscaleTile(memTotalMb: 40000), 512); + expect(androidUpscaleTile(memTotalMb: 6000), 192); + expect(androidUpscaleTile(longSide: 1024), 256); + expect(androidUpscaleTile(longSide: 800), 512); + }); + + testWidgets('[이 장] 은 작업 목록에 오르고, [뒤에서 계속] 으로 화면을 떠나도 끝나며 [보기] 로 비교 · 저장', (tester) async { + final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + var finished = false; + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: Builder( + builder: (ctx) => TextButton( + onPressed: () => upscaleOne(ctx, c, input: input, nameFrom: input, outDir: tmp.path).whenComplete(() => finished = true), + child: const Text('올리기'), + ), + ), + ), + )); + await tester.tap(find.text('올리기')); + for (var i = 0; i < 20 && !AiJobs.instance.busy; i++) { + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 20))); + await tester.pump(); + } + // 작업 목록 (알림 · 작업 현황) 에 오른다 + expect(AiJobs.instance.busy, isTrue); + expect(AiJobs.instance.statusLine, contains('page.png')); + // 화면을 떠난다 (진행 창만 닫힘, 작업은 계속) + await tester.tap(find.text('뒤에서 계속')); + await tester.pumpAndSettle(); + expect(find.text('AI 해상도 올리기'), findsNothing); + expect(AiJobs.instance.busy, isTrue); + // 엔진이 끝나면 비교 화면을 띄우지 않고 [보기] 를 남긴다 + engine.complete(); + // 시험의 가짜 시간 안에서 시작한 일이라 기다리지 말고 끝날 때까지 돌린다 + for (var i = 0; i < 200 && !finished; i++) { + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 20))); + await tester.pump(); + } + expect(finished, isTrue); + await tester.pumpAndSettle(); + expect(AiJobs.instance.busy, isFalse); + expect(AiJobs.instance.lastDone, contains('page.png')); + expect(find.byType(UpscaleCompareView), findsNothing); + final view = AiJobs.instance.lastDoneView; + expect(view, isNotNull); + // [보기] → 비교 화면 → [저장] + var viewed = false; + view!().whenComplete(() => viewed = true); + await tester.pumpAndSettle(); + expect(find.byType(UpscaleCompareView), findsOneWidget); + await tester.runAsync(() => tester.tap(find.widgetWithText(FilledButton, '저장'))); + for (var i = 0; i < 100 && !viewed; i++) { + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 20))); + await tester.pump(); + } + expect(viewed, isTrue); + final saved = [for (final f in tmp.listSync(recursive: true).whereType()) if (!p.equals(f.path, input)) f.path]; + expect(saved, isNotEmpty, reason: '저장됨 (원본 옆, 새 파일)'); + expect(AiJobs.instance.lastDoneView, isNull, reason: '[보기] 는 한 번'); + ScaffoldMessenger.of(tester.element(find.text('올리기'))).clearSnackBars(); + await tester.pumpAndSettle(); + }); +}