diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 0e9aac5..d0b6747 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -1142,6 +1142,8 @@ "마지막으로: {0}": "To the last: {0}", "막은 경로 {0}개": "{0} paths blocked", "만든 그림 · 저장: {0}": "Made images · saved in: {0}", + "만든 날": "Created", + "만든 때: {0}": "Created: {0}", "만들 MKV 가 이미 있으면": "If the MKV already exists", "만들 자막 언어 (파일명_언어코드.srt)": "Subtitle languages to make (filename_languagecode.srt)", "만들 파일: {0}": "File to create: {0}", @@ -1575,6 +1577,8 @@ "속도 제한": "Speed limit", "속도 제한 (KB/s)": "Speed limit (KB/s)", "속도 제한 {0} KB/s": "Speed limit {0} KB/s", + "속성": "Attributes", + "속성: {0}": "Attributes: {0}", "수": "Wed", "수정": "Edit", "수정 · 이동": "Edit · move", @@ -1600,6 +1604,7 @@ "시드 (-1 은 아무렇게나)": "Seed (-1 = random)", "시드 {0}": "Seed {0}", "시리즈: file_001 · file_002, S01E01 · S01E02 처럼 번호만 다른 파일": "Series: files that differ only by number, like file_001 · file_002, S01E01 · S01E02", + "시스템": "System", "시스템 DNS": "System DNS", "시스템 DNS (끄기)": "System DNS (off)", "시스템 DNS 로 바꾸기": "Switch to system DNS", @@ -1921,6 +1926,7 @@ "위 ⇄ 아래": "Top ⇄ bottom", "위 폴더": "Parent folder", "위 폴더로": "Go up", + "위 폴더로 가기": "Go to a parent folder", "위로": "Up", "위아래": "Top and bottom", "위아래 ⇅": "Stacked ⇅", @@ -2092,6 +2098,7 @@ "일반 오류": "general failure", "일본어": "Japanese", "일시정지": "Pause", + "읽기 전용": "Read-only", "읽기 전용 · 보기와 받기만 됩니다": "Read-only · view and download only", "읽기 전용 공유입니다 (보기 · 받기만)": "This share is read-only (view and download only)", "읽기 전용 매체입니다": "Read-only media", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index ce4dc2a..c66e2be 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -1142,6 +1142,8 @@ "마지막으로: {0}": "最後へ: {0}", "막은 경로 {0}개": "ブロックしたパス {0} 件", "만든 그림 · 저장: {0}": "作成した画像・保存先: {0}", + "만든 날": "作成日", + "만든 때: {0}": "作成日時: {0}", "만들 MKV 가 이미 있으면": "作る MKV がすでにあるとき", "만들 자막 언어 (파일명_언어코드.srt)": "作成する字幕の言語(ファイル名_言語コード.srt)", "만들 파일: {0}": "作成するファイル: {0}", @@ -1575,6 +1577,8 @@ "속도 제한": "速度制限", "속도 제한 (KB/s)": "速度制限 (KB/s)", "속도 제한 {0} KB/s": "速度制限 {0} KB/s", + "속성": "属性", + "속성: {0}": "属性: {0}", "수": "水", "수정": "編集", "수정 · 이동": "編集・移動", @@ -1600,6 +1604,7 @@ "시드 (-1 은 아무렇게나)": "シード (-1 はランダム)", "시드 {0}": "シード {0}", "시리즈: file_001 · file_002, S01E01 · S01E02 처럼 번호만 다른 파일": "シリーズ: file_001・file_002、S01E01・S01E02 のように番号だけ違うファイル", + "시스템": "システム", "시스템 DNS": "システム DNS", "시스템 DNS (끄기)": "システム DNS (オフ)", "시스템 DNS 로 바꾸기": "システム DNS に切り替える", @@ -1921,6 +1926,7 @@ "위 ⇄ 아래": "上 ⇄ 下", "위 폴더": "上のフォルダー", "위 폴더로": "上のフォルダーへ", + "위 폴더로 가기": "上のフォルダーへ移動", "위로": "上へ", "위아래": "上下", "위아래 ⇅": "上下 ⇅", @@ -2092,6 +2098,7 @@ "일반 오류": "一般エラー", "일본어": "日本語", "일시정지": "一時停止", + "읽기 전용": "読み取り専用", "읽기 전용 · 보기와 받기만 됩니다": "読み取り専用 · 表示とダウンロードのみ", "읽기 전용 공유입니다 (보기 · 받기만)": "読み取り専用の共有です (表示 · ダウンロードのみ)", "읽기 전용 매체입니다": "読み取り専用メディアです", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 9df00ae..69a2e5e 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -1142,6 +1142,8 @@ "마지막으로: {0}": "到最后: {0}", "막은 경로 {0}개": "已阻止 {0} 个路径", "만든 그림 · 저장: {0}": "生成的图片 · 保存位置: {0}", + "만든 날": "创建日期", + "만든 때: {0}": "创建时间:{0}", "만들 MKV 가 이미 있으면": "要制作的 MKV 已存在时", "만들 자막 언어 (파일명_언어코드.srt)": "要生成的字幕语言(文件名_语言代码.srt)", "만들 파일: {0}": "要创建的文件: {0}", @@ -1575,6 +1577,8 @@ "속도 제한": "速度限制", "속도 제한 (KB/s)": "速度限制 (KB/s)", "속도 제한 {0} KB/s": "速度限制 {0} KB/s", + "속성": "属性", + "속성: {0}": "属性:{0}", "수": "三", "수정": "编辑", "수정 · 이동": "编辑 · 移动", @@ -1600,6 +1604,7 @@ "시드 (-1 은 아무렇게나)": "种子 (-1 为随机)", "시드 {0}": "种子 {0}", "시리즈: file_001 · file_002, S01E01 · S01E02 처럼 번호만 다른 파일": "系列: 像 file_001 · file_002、S01E01 · S01E02 这样仅编号不同的文件", + "시스템": "系统", "시스템 DNS": "系统 DNS", "시스템 DNS (끄기)": "系统 DNS (关闭)", "시스템 DNS 로 바꾸기": "切换为系统 DNS", @@ -1921,6 +1926,7 @@ "위 ⇄ 아래": "上 ⇄ 下", "위 폴더": "上级文件夹", "위 폴더로": "返回上级文件夹", + "위 폴더로 가기": "转到上级文件夹", "위로": "向上", "위아래": "上下", "위아래 ⇅": "上下 ⇅", @@ -2092,6 +2098,7 @@ "일반 오류": "一般错误", "일본어": "日语", "일시정지": "暂停", + "읽기 전용": "只读", "읽기 전용 · 보기와 받기만 됩니다": "只读 · 仅可查看和下载", "읽기 전용 공유입니다 (보기 · 받기만)": "这是只读共享 (仅可查看和下载)", "읽기 전용 매체입니다": "只读介质", diff --git a/integration_test/windows_p3_shot_test.dart b/integration_test/windows_p3_shot_test.dart new file mode 100644 index 0000000..0fc0f72 --- /dev/null +++ b/integration_test/windows_p3_shot_test.dart @@ -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(); + 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 settle([int n = 8]) async { + for (var i = 0; i < n; i++) { + await t.runAsync(() => Future.delayed(const Duration(milliseconds: 60))); + await t.pump(); + } + } + + Future 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 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(builder: (_) => HexViewerPage(path: file.path))); + await settle(12); + await shot('hex'); + await closeAll(); + }); + } + } +} diff --git a/lib/core/file_attributes.dart b/lib/core/file_attributes.dart new file mode 100644 index 0000000..0dda27f --- /dev/null +++ b/lib/core/file_attributes.dart @@ -0,0 +1,49 @@ +import 'dart:ffi'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:ffi/ffi.dart'; +import 'package:path/path.dart' as p; + +import '../l10n/tr.dart'; + +/// X-plore P3 보충 (관리자 지시 10/18): 파일 · 폴더의 숨김 · 읽기 전용 · 시스템 속성과 만든 때 - 보기만 (바꾸지 않음). +/// Windows 는 GetFileAttributesExW (kernel32), 그 밖 (Android) 은 이름이 점으로 시작하면 숨김 · 만든 때는 모름. +class FileAttributes { + final bool hidden; + final bool readOnly; + final bool system; + + /// 만든 때 (모르면 null) + final DateTime? created; + const FileAttributes({this.hidden = false, this.readOnly = false, this.system = false, this.created}); + + /// 화면에 보일 속성 이름들 (없으면 빈 목록) + List get labels => [if (hidden) tr('숨김'), if (readOnly) tr('읽기 전용'), if (system) tr('시스템')]; +} + +typedef _GetAttrsExC = Int32 Function(Pointer name, Int32 level, Pointer info); +typedef _GetAttrsExD = int Function(Pointer name, int level, Pointer info); + +/// [path] 의 속성 (읽지 못하면 null). 로컬 경로만 (원격은 부르지 않는다) +FileAttributes? readFileAttributes(String path) { + if (!Platform.isWindows) return FileAttributes(hidden: p.basename(path).startsWith('.')); + final name = path.toNativeUtf16(); + // WIN32_FILE_ATTRIBUTE_DATA: DWORD 속성 · FILETIME 만든 · 열어 본 · 바뀐 때 · DWORD 크기 (위 · 아래) = 36 바이트 + final info = calloc(36); + try { + final f = DynamicLibrary.open('kernel32.dll').lookupFunction<_GetAttrsExC, _GetAttrsExD>('GetFileAttributesExW'); + if (f(name, 0 /* GetFileExInfoStandard */, info) == 0) return null; + final b = ByteData.sublistView(info.asTypedList(36)); + final attrs = b.getUint32(0, Endian.little); + final ft = b.getUint32(4, Endian.little) | (b.getUint32(8, Endian.little) << 32); + // FILETIME: 1601-01-01 부터 100ns 단위 + final created = ft == 0 ? null : DateTime.fromMicrosecondsSinceEpoch(ft ~/ 10 - 11644473600000000, isUtc: true).toLocal(); + return FileAttributes(hidden: attrs & 0x2 != 0, readOnly: attrs & 0x1 != 0, system: attrs & 0x4 != 0, created: created); + } catch (_) { + return null; + } finally { + calloc.free(name); + calloc.free(info); + } +} diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index 0cdf633..3c0d04e 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -54,6 +54,7 @@ import 'explorer_bookmarks.dart'; import 'explorer_search.dart'; import 'quick_look.dart'; import 'file_tools.dart' show showFileHash, showFolderProperties; +import '../core/file_attributes.dart' show readFileAttributes; import 'explorer_drag.dart'; import 'archive_ui.dart'; import 'explorer_grid.dart'; @@ -3584,6 +3585,8 @@ class _ExplorerPageState extends State { Future _info(FileEntry e) async { final size = e.isDir ? await FileOps.totalSize([e.path]) : e.size; final meta = !e.isDir && !isDav(e.path) && isVideoFile(e.path) ? await _Meta.of(c, e.path) : null; + // 관리자 지시 10/18: 만든 때 · 숨김 · 읽기 전용 · 시스템 (보기만, 이 기기의 것만) + final attrs = isDav(e.path) ? null : readFileAttributes(e.path); if (!mounted) return; await showDialog( context: context, @@ -3593,7 +3596,9 @@ class _ExplorerPageState extends State { content: SelectableText([ e.path, '${tr('크기')}: ${formatSize(size)}', + if (attrs?.created != null) '${tr('만든 날')}: ${_date(attrs!.created!)}', if (e.modified.year > 1) '${tr('바뀐 날')}: ${_date(e.modified)}', + if (attrs != null) '${tr('속성')}: ${attrs.labels.isEmpty ? tr('없음') : attrs.labels.join(' · ')}', if (meta != null && meta.isNotEmpty) '${tr('동영상')}: $meta', ].join('\n')), actions: [TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('닫기')))], @@ -4073,15 +4078,45 @@ class _ExplorerPageState extends State { ); } + /// 지금 폴더 · 그 위 폴더들 (창의 맨 위 [pane.root] 까지, 가까운 것부터) + List _ancestors(_Pane pane) { + final out = []; + 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) : '', - maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(color: JjColors.textDim)), + child: !_ready + ? const SizedBox.shrink() + : PopupMenuButton( + 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( tooltip: tr('창 배치 · 버튼 구성'), diff --git a/lib/ui/file_tools.dart b/lib/ui/file_tools.dart index ff546fa..62a9087 100644 --- a/lib/ui/file_tools.dart +++ b/lib/ui/file_tools.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:path/path.dart' as p; +import '../core/file_attributes.dart'; import '../core/file_ops.dart' show formatSize; import '../l10n/tr.dart'; import 'theme.dart'; @@ -112,7 +113,8 @@ Future showFileHash(BuildContext context, String path) async { ); }), ); - compare.dispose(); + // 창이 닫히는 동안 (애니메이션) 글 칸이 아직 쓰므로 바로 dispose 하면 "used after being disposed" (15 캡처) - 닫힌 뒤에 + Future.delayed(const Duration(milliseconds: 500), compare.dispose); } /// 16진 보기: 한 줄 16 바이트 (위치 · 16진 · 글자). 큰 파일은 [더 읽기] @@ -121,12 +123,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 bytes) { + /// 한 줄 ([per] 바이트, 처음 16): "00000010 41 42 ... AB.." + static String line(int offset, List 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 +165,9 @@ class _HexViewerPageState extends State { @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 +180,7 @@ class _HexViewerPageState extends State { 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, @@ -225,6 +229,7 @@ Future<({int files, int folders, int bytes, int links})> folderStats(String dir, Future showFolderProperties(BuildContext context, String dir) async { var cancelled = false; final stat = await FileStat.stat(dir); + final attrs = readFileAttributes(dir); if (!context.mounted) return; String date(DateTime d) => '${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')} ${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}'; @@ -250,8 +255,11 @@ Future showFolderProperties(BuildContext context, String dir) async { if (r.links > 0) Text(trf('링크 · junction {0}개 (따라가지 않고 세지 않음)', [r.links])), ], const SizedBox(height: 6), + // 관리자 지시 10/18: 만든 때 · 숨김 · 읽기 전용 (보기만) + if (attrs?.created != null) Text(trf('만든 때: {0}', [date(attrs!.created!)])), Text(trf('바뀐 때: {0}', [date(stat.modified)])), Text(trf('열어 본 때: {0}', [date(stat.accessed)])), + Text(trf('속성: {0}', [attrs == null || attrs.labels.isEmpty ? tr('없음') : attrs.labels.join(' · ')])), ]); }, ), diff --git a/test/explorer_test.dart b/test/explorer_test.dart index 7d05a16..8932d9f 100644 --- a/test/explorer_test.dart +++ b/test/explorer_test.dart @@ -196,6 +196,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); + final n = items.evaluate().length; + expect(n, greaterThanOrEqualTo(2), reason: 'sub · 그 위 폴더들 (창의 맨 위까지)'); + expect(tester.widget>(items.first).enabled, isFalse, reason: '지금 폴더는 고를 수 없음'); + expect(tester.widget>(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), findsNWidgets(n - 1)); + }); + testWidgets('X-plore b: 가운데 버튼 길게 누르기 = 짝 동작 - [복사] 길게 = 이동 창 · [삭제] 길게 = 영구 삭제 창', (tester) async { await open(tester); await act(tester, () => tester.tap(find.text('doc.txt'))); diff --git a/test/file_attributes_test.dart b/test/file_attributes_test.dart new file mode 100644 index 0000000..b899278 --- /dev/null +++ b/test/file_attributes_test.dart @@ -0,0 +1,41 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/core/file_attributes.dart'; +import 'package:path/path.dart' as p; + +/// P3 보충: 숨김 · 읽기 전용 · 만든 때 (Windows GetFileAttributesExW) - 시험 파일에만 attrib 로 속성을 켜고 끝에 끈다 +void main() { + late Directory tmp; + setUp(() => tmp = Directory.systemTemp.createTempSync('jj_attrs_')); + tearDown(() async { + if (Platform.isWindows) await Process.run('attrib', ['-h', '-r', '-s', p.join(tmp.path, '*'), '/s', '/d']); + tmp.deleteSync(recursive: true); + }); + + test('보통 파일: 속성 없음 · 만든 때는 지금 즈음', () { + final f = File(p.join(tmp.path, 'a.txt'))..writeAsStringSync('a'); + final a = readFileAttributes(f.path)!; + expect(a.labels, isEmpty); + if (Platform.isWindows) { + expect(a.created, isNotNull); + expect(DateTime.now().difference(a.created!).inMinutes.abs(), lessThan(5)); + } + }); + + test('숨김 · 읽기 전용 (Windows attrib) · 폴더', () async { + final f = File(p.join(tmp.path, 'b.txt'))..writeAsStringSync('b'); + final d = Directory(p.join(tmp.path, 'd'))..createSync(); + if (!Platform.isWindows) { + expect(readFileAttributes(p.join(tmp.path, '.hidden'))!.hidden, isTrue); + return; + } + await Process.run('attrib', ['+h', '+r', f.path]); + await Process.run('attrib', ['+h', d.path]); + final a = readFileAttributes(f.path)!; + expect((a.hidden, a.readOnly, a.system), (true, true, false)); + expect(a.labels, ['숨김', '읽기 전용']); + expect(readFileAttributes(d.path)!.hidden, isTrue); + expect(readFileAttributes(p.join(tmp.path, '없음')), isNull); + }); +}