WIP X-plore P3 보충 (캡처에서 찾음) · 경로 막대 누르기로 위 폴더 고르기 (검토 대기)

캡처 (integration_test/windows_p3_shot_test.dart - 해시 · 비교 · 폴더 속성 · 16진, 412/960 × ko/en/ja, release\test\shots_p3_fix) 에서 찾아 고침:
- 16진 보기: 폭 412 에서 한 줄 16 바이트면 끝의 글자 칸이 화면 밖으로 잘렸다 → 폭 560 미만은 한 줄 8 바이트 (line(per:), 처음 값 16 그대로)
- 해시 창: 닫자마자 글 칸 컨트롤러를 dispose 해 닫히는 동안 "A TextEditingController was used after being disposed" (닫을 때마다) → 닫힌 뒤 dispose
관리자 지시 3 (X-plore 비교표의 작은 것): 위 막대의 경로를 누르면 지금 폴더 · 위 폴더들 (창의 맨 위까지, 가까운 것부터) 이 나와 고르면 그리로 (X-plore 4.4x 경로 막대).
지금 폴더는 고를 수 없게 흐리게.

시험: explorer_test (새: 경로 막대 - 지금 폴더 못 고름 · 바로 위 = left · 고르면 하나 적어짐) · file_tools_test 통과, analyze 문제 없음, l10n 빠짐 0, 캡처 시험 6 통과.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
xplore_p3
RyuJongJin 2026-10-18 01:43:17 +09:00
parent fba28cb027
commit 8eb1674382
7 changed files with 148 additions and 9 deletions

View File

@ -1778,6 +1778,7 @@
"위 ⇄ 아래": "Top ⇄ bottom",
"위 폴더": "Parent folder",
"위 폴더로": "Go up",
"위 폴더로 가기": "Go to a parent folder",
"위로": "Up",
"위아래": "Top and bottom",
"위아래 ⇅": "Stacked ⇅",

View File

@ -1778,6 +1778,7 @@
"위 ⇄ 아래": "上 ⇄ 下",
"위 폴더": "上のフォルダー",
"위 폴더로": "上のフォルダーへ",
"위 폴더로 가기": "上のフォルダーへ移動",
"위로": "上へ",
"위아래": "上下",
"위아래 ⇅": "上下 ⇅",

View File

@ -1778,6 +1778,7 @@
"위 ⇄ 아래": "上 ⇄ 下",
"위 폴더": "上级文件夹",
"위 폴더로": "返回上级文件夹",
"위 폴더로 가기": "转到上级文件夹",
"위로": "向上",
"위아래": "上下",
"위아래 ⇅": "上下 ⇅",

View File

@ -0,0 +1,84 @@
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/i18n_controller.dart';
import 'package:jj_mkvmaker/ui/file_tools.dart';
import 'package:jj_mkvmaker/ui/theme.dart';
import 'package:path/path.dart' as p;
/// 관리자 지시 (10/18): X-plore P3 화면을 PNG 로 (JJ_SHOT_DIR, 없으면 임시 폴더) - 해시 창 · 폴더 속성 · 16진 보기.
/// 크기 412 (폰 세로) · 960, 언어 ko · en · ja. 시험 파일은 임시 폴더에만 (끝나면 지움). 실행: flutter test integration_test\windows_p3_shot_test.dart -d windows
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
final out = Platform.environment['JJ_SHOT_DIR'] ?? p.join(Directory.systemTemp.path, 'jj_shots_p3');
for (final lang in ['ko', 'en', 'ja']) {
for (final (w, h) in [(412, 900), (960, 600)]) {
testWidgets('P3 화면 $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_p3shot_');
addTearDown(() => tmp.deleteSync(recursive: true));
final file = File(p.join(tmp.path, '영상 폴더', 'ep01 긴 이름의 동영상 파일.mkv'))
..createSync(recursive: true)
..writeAsBytesSync(List.generate(5000, (i) => i % 256));
File(p.join(tmp.path, '영상 폴더', '속', 'a.txt'))
..createSync(recursive: true)
..writeAsStringSync('hello');
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())),
));
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, 'p3_${name}_${lang}_$w.png')).writeAsBytesSync(bytes!.buffer.asUint8List());
}
Future<void> closeAll() async {
navKey.currentState!.popUntil((r) => r.isFirst);
await settle(4);
}
// 해시 창 (다 계산된 뒤 · 붙여 넣은 값과 비교)
showFileHash(ctx(), file.path);
await settle(12);
await shot('hash');
await t.enterText(find.byType(TextField).first, 'abc');
await settle();
await shot('hash_compare');
await closeAll();
// 폴더 속성
showFolderProperties(ctx(), p.join(tmp.path, '영상 폴더'));
await settle(12);
await shot('props');
await closeAll();
// 16진 보기
navKey.currentState!.push(MaterialPageRoute<void>(builder: (_) => HexViewerPage(path: file.path)));
await settle(12);
await shot('hex');
await closeAll();
});
}
}
}

View File

@ -3698,16 +3698,46 @@ class _ExplorerPageState extends State<ExplorerPage> {
);
}
/// 지금 폴더 · 그 위 폴더들 (창의 맨 위 [pane.root] 까지, 가까운 것부터)
List<String> _ancestors(_Pane pane) {
final out = <String>[];
var d = pane.current;
for (var i = 0; i < 64; i++) {
out.add(d);
if (samePath(d, pane.root)) break;
final up = vDirname(d);
if (up == d || up.isEmpty) break;
d = up;
}
return out;
}
Widget _topBar() => AppTopBar(
nav: AppNavButtons(onExplorerPage: !widget.rsync, onRsyncPage: widget.rsync),
actions: AppActions(c: c),
middle: Row(children: [
Text(widget.rsync ? 'Rsync' : tr('파일 탐색기'), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
const SizedBox(width: 12),
// X-plore 경로 막대 (4.4x): 경로를 누르면 위 폴더들 (창의 맨 위까지) 이 나와 골라 간다
Expanded(
child: Text(_ready ? _displayPath(_pane.current) : '',
child: !_ready
? const SizedBox.shrink()
: PopupMenuButton<String>(
tooltip: tr('위 폴더로 가기'),
position: PopupMenuPosition.under,
onSelected: (d) => _goTo(_pane, d),
itemBuilder: (_) => [
for (final d in _ancestors(_pane))
PopupMenuItem(
value: d,
enabled: !samePath(d, _pane.current),
child: Text(_displayPath(d), maxLines: 1, overflow: TextOverflow.ellipsis),
),
],
child: Text(_displayPath(_pane.current),
maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(color: JjColors.textDim)),
),
),
PopupMenuButton<ExplorerButton>(
tooltip: tr('창 배치 · 버튼 구성'),
icon: const Icon(Icons.more_vert),

View File

@ -112,7 +112,8 @@ Future<void> showFileHash(BuildContext context, String path) async {
);
}),
);
compare.dispose();
// 창이 닫히는 동안 (애니메이션) 글 칸이 아직 쓰므로 바로 dispose 하면 "used after being disposed" (15 캡처) - 닫힌 뒤에
Future<void>.delayed(const Duration(milliseconds: 500), compare.dispose);
}
/// 16진 보기: 한 줄 16 바이트 (위치 · 16진 · 글자). 큰 파일은 [더 읽기]
@ -121,12 +122,12 @@ class HexViewerPage extends StatefulWidget {
final int chunk;
const HexViewerPage({super.key, required this.path, this.chunk = 64 * 1024});
/// 한 줄 (16 바이트): "00000010 41 42 ... AB.."
static String line(int offset, List<int> bytes) {
/// 한 줄 ([per] 바이트, 처음 16): "00000010 41 42 ... AB.."
static String line(int offset, List<int> bytes, {int per = 16}) {
final hex = StringBuffer();
for (var i = 0; i < 16; i++) {
for (var i = 0; i < per; i++) {
hex.write(i < bytes.length ? bytes[i].toRadixString(16).padLeft(2, '0') : ' ');
hex.write(i == 7 ? ' ' : ' ');
hex.write(i == 7 && per > 8 ? ' ' : ' ');
}
final text = String.fromCharCodes([for (final b in bytes) b >= 0x20 && b < 0x7f ? b : 0x2e]);
return '${offset.toRadixString(16).padLeft(8, '0')} $hex $text';
@ -163,7 +164,9 @@ class _HexViewerPageState extends State<HexViewerPage> {
@override
Widget build(BuildContext context) {
final rows = (_data.length + 15) ~/ 16;
// 좁은 화면 (폰 세로) 은 한 줄 8 바이트 - 16 이면 끝의 글자 칸이 화면 밖으로 잘렸다 (15 캡처, 412 폭)
final per = MediaQuery.sizeOf(context).width < 560 ? 8 : 16;
final rows = (_data.length + per - 1) ~/ per;
return Scaffold(
appBar: AppBar(title: Text(trf('16진 보기 · {0}', [p.basename(widget.path)]), maxLines: 1, overflow: TextOverflow.ellipsis)),
body: _error != null
@ -176,7 +179,7 @@ class _HexViewerPageState extends State<HexViewerPage> {
itemCount: rows,
itemExtent: 18,
itemBuilder: (_, i) => Text(
HexViewerPage.line(i * 16, _data.sublist(i * 16, (i * 16 + 16).clamp(0, _data.length))),
HexViewerPage.line(i * per, _data.sublist(i * per, (i * per + per).clamp(0, _data.length)), per: per),
style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
maxLines: 1,
softWrap: false,

View File

@ -193,6 +193,25 @@ void main() {
await calm(tester);
});
testWidgets('X-plore 경로 막대: 위 막대의 경로를 누르면 지금 폴더 · 위 폴더들 (창의 맨 위까지) - 고르면 그리로', (tester) async {
await open(tester);
await act(tester, () => tester.tap(find.text('sub')));
await settle(tester, () => find.text('inner.txt').evaluate().isNotEmpty);
await act(tester, () => tester.tap(find.byTooltip('위 폴더로 가기')));
await calm(tester);
final items = find.byType(PopupMenuItem<String>);
final n = items.evaluate().length;
expect(n, greaterThanOrEqualTo(2), reason: 'sub · 그 위 폴더들 (창의 맨 위까지)');
expect(tester.widget<PopupMenuItem<String>>(items.first).enabled, isFalse, reason: '지금 폴더는 고를 수 없음');
expect(tester.widget<PopupMenuItem<String>>(items.at(1)).value, left, reason: '바로 위 = left');
await act(tester, () => tester.tap(items.at(1)));
await settle(tester, () => false, rounds: 15);
// 위로 갔으면 다시 열 때 하나 적다 (지금 폴더 = left)
await act(tester, () => tester.tap(find.byTooltip('위 폴더로 가기')));
await calm(tester);
expect(find.byType(PopupMenuItem<String>), findsNWidgets(n - 1));
});
testWidgets('X-plore b: 가운데 버튼 길게 누르기 = 짝 동작 - [복사] 길게 = 이동 창 · [삭제] 길게 = 영구 삭제 창', (tester) async {
await open(tester);
await act(tester, () => tester.tap(find.text('doc.txt')));