diff --git a/lib/core/selection.dart b/lib/core/selection.dart new file mode 100644 index 0000000..feb2d80 --- /dev/null +++ b/lib/core/selection.dart @@ -0,0 +1,225 @@ +import 'file_ops.dart' show isSameOrInside, samePath; +import 'vfs.dart' show vDirname; + +/// 파일 탐색기의 누르기 · 고르기 (E1, 관리자 c5 승인 10/22). +/// +/// 기준: wiki "10 wiki/규칙/파일 탐색기 선택 규칙 (사용자 지시 모음)" 의 규칙 2~6 · 8. 화면은 이 모듈을 부르기만 한다. +/// +/// 약속 +/// - [ClickPolicy.decide]: 누르기 (한 번 · 두 번 · 길게 · 오른쪽) · 항목 종류 · 선택 모드 · 펼침 → 할 일 하나 ([ClickAction]). +/// 모르는 조합은 [ClickAction.none] (아무것도 하지 않음). 화면 · 기기 · 시각을 모른다 (같은 입력 = 같은 결과). +/// - [Selection]: 창 하나의 표시 (고른 경로 집합). +/// · 파일: 누르면 고름, 다시 누르면 취소 (규칙 5) +/// · 폴더: 누르면 자기 + 안의 것 전체, 다시 누르면 자기와 안의 것 전체 취소 (규칙 6) +/// · 고른 폴더 안의 항목 하나를 누르면 그것만 빠짐 (나머지는 고른 채) +/// · 그렇게 하나를 뺀 폴더를 다시 누르면 그 폴더와 안의 것 전체 취소 (관리자 결정 10/22) +/// · Rsync ([Selection.rsync]): 폴더만, 하나만 (다시 누르면 취소) (규칙 8) +/// 오류: 안의 목록을 읽지 못하면 ([SelectionLister] 가 던짐) 표시를 바꾸지 않고 그대로 던진다 (반쯤 바뀐 상태를 남기지 않음). +/// - 경로 비교는 [samePath] · [isSameOrInside] (Windows 대소문자 무시 · WebDAV 주소) 를 그대로 쓴다. + +/// 줄의 종류 +enum ItemKind { + file, + folder, + + /// 저장 장치 · 서버 · 즐겨찾기 같은 트리 맨 위 (고를 수 없음) + root, + + /// 목록의 ".." (위 폴더로) + up, +} + +/// 누르기 +enum Press { tap, doubleTap, longPress, secondary } + +/// 누르기의 결과 (화면이 할 일) +enum ClickAction { + none, + + /// 지금 줄로 (파일 한 번 누르기 = 선택) + focus, + + /// 실행 (파일) · 들어가기 (목록의 폴더 · ..) + open, + + /// 트리 폴더 펼치기 + expand, + + /// 트리 폴더 접기 + collapse, + + /// 기능 메뉴 (복사 · 이동 · 삭제 …) + menu, + + /// 표시 바꾸기 ([Selection.toggle]) + toggleMark, +} + +/// 규칙 2 · 3 · 5 · 6: 누르기 → 할 일 +class ClickPolicy { + const ClickPolicy._(); + + /// [selectMode] 선택 모드 (가운데 [선택]) · [expanded] 그 폴더가 펼쳐져 있는지 · + /// [list] 트리가 아니라 목록 (폴더 + 목록 배치의 오른쪽) 의 줄인지 · + /// [repeat] 같은 줄을 두 번 누르기 시간 안에 다시 누른 것인지 (폴더는 두 번 누르기를 기다리지 않으므로, + /// 두 번째 누르기로 방금 펼친 것을 접지 않게) + static ClickAction decide(Press press, ItemKind kind, + {required bool selectMode, bool expanded = false, bool list = false, bool repeat = false}) { + final real = kind == ItemKind.file || kind == ItemKind.folder; + switch (press) { + case Press.longPress: + case Press.secondary: + return real ? ClickAction.menu : ClickAction.none; + case Press.doubleTap: + // 두 번 = 실행 (파일만, 선택 모드 밖). 폴더 · 선택 모드는 한 번 누르기로 이미 처리됨 + return kind == ItemKind.file && !selectMode ? ClickAction.open : ClickAction.none; + case Press.tap: + if (kind == ItemKind.up) return ClickAction.open; + if (selectMode && real) return ClickAction.toggleMark; + if (kind == ItemKind.file) return ClickAction.focus; + if (list && kind == ItemKind.folder) return ClickAction.open; + if (!expanded) return ClickAction.expand; + return repeat ? ClickAction.none : ClickAction.collapse; + } + } +} + +/// 폴더 안의 경로들 (바로 아래만). 읽지 못하면 던진다. +typedef SelectionLister = Future> Function(String dir); + +/// 창 하나의 표시 (규칙 5 · 6 · 8) +class Selection { + Selection({this.rsync = false}); + + /// Rsync 화면: 폴더만 · 하나만 (규칙 8) + final bool rsync; + + final List _marked = []; + + /// 고른 폴더에서 하나를 빼서 나눈 폴더 (다시 누르면 전체 취소 - 관리자 결정 10/22) + final List _split = []; + + /// 범위 고르기의 시작 (마지막으로 고른 것) + String? anchor; + + /// 고른 경로 (바깥 것만 - 폴더를 고르면 안의 것은 따로 들지 않음) + List get marked => List.unmodifiable(_marked); + + bool get isEmpty => _marked.isEmpty; + + /// [path] 가 표시되었는지 (자기 자신, 또는 위 폴더가 표시됨). Rsync 는 그 폴더만. + bool isMarked(String path) => _marked.any((m) => rsync ? samePath(path, m) : isSameOrInside(path, m)); + + /// [path] 를 [Selection] 의 규칙대로 바꾼다. 폴더를 나눌 때만 [list] 로 안을 읽는다. + Future toggle(String path, {required bool isDir, required SelectionLister list}) async { + if (rsync) { + if (!isDir) return; + final again = _marked.length == 1 && samePath(_marked.first, path); + _marked.clear(); + if (!again) _marked.add(path); + return; + } + if (isMarked(path)) { + final owner = _marked.firstWhere((m) => isSameOrInside(path, m)); + if (samePath(owner, path)) { + _clearInside(path); + return; + } + // 위 폴더 [owner] 로 함께 골라진 것: 그것만 빼고 owner 에서 path 까지 가는 길의 다른 항목은 고른 채로 + final keep = []; + final splits = []; + var dir = owner; + while (!samePath(dir, path)) { + final kids = await list(dir); + splits.add(dir); + String? next; + for (final k in kids) { + if (isSameOrInside(path, k)) { + next = k; + } else { + keep.add(k); + } + } + if (next == null) break; + dir = next; + } + _marked.removeWhere((m) => samePath(m, owner)); + _marked.addAll(keep); + _split.addAll(splits); + return; + } + if (isDir && _split.any((s) => samePath(s, path))) { + // 하나를 뺀 폴더를 다시 누름: 그 폴더와 안의 것 전체 취소 + _clearInside(path); + return; + } + _marked.removeWhere((m) => isSameOrInside(m, path)); // 안에 따로 고른 것은 이 폴더 하나로 + _split.removeWhere((s) => isSameOrInside(s, path)); + _marked.add(path); + anchor = path; + } + + /// [from] (보통 [anchor]) 부터 [to] 까지 [visible] (보이는 차례) 의 것을 모두 고른다. 둘 중 하나가 없으면 그대로. + void range(List visible, String to, {String? from}) { + final a = visible.indexWhere((x) => samePath(x, from ?? anchor ?? '')); + final b = visible.indexWhere((x) => samePath(x, to)); + if (a < 0 || b < 0 || rsync) return; + for (var i = a < b ? a : b; i <= (a < b ? b : a); i++) { + if (!isMarked(visible[i])) _marked.add(visible[i]); + } + _normalize(); + anchor = to; + } + + /// [paths] 를 모두 고른다 (모두 선택 · 틀에 맞는 것) + void addAll(Iterable paths) { + if (rsync) return; + for (final x in paths) { + if (!isMarked(x)) _marked.add(x); + } + _normalize(); + } + + /// [paths] 중 고른 것은 풀고 안 고른 것은 고른다 (뒤집기) + void invert(Iterable paths) { + if (rsync) return; + for (final x in paths) { + if (_marked.any((m) => samePath(m, x))) { + _marked.removeWhere((m) => samePath(m, x)); + } else if (!isMarked(x)) { + _marked.add(x); + } + } + _normalize(); + } + + /// 지워지거나 옮겨진 경로를 표시에서 뺀다 (안의 것 포함) + void forget(String path) { + _marked.removeWhere((m) => isSameOrInside(m, path)); + _split.removeWhere((s) => isSameOrInside(s, path)); + } + + void clear() { + _marked.clear(); + _split.clear(); + anchor = null; + } + + /// 일할 대상: 고른 것, 없으면 지금 줄 [focused] 하나 (없으면 빈 목록) + List targets(String? focused) => _marked.isNotEmpty ? marked : [?focused]; + + /// [dir] 바로 안에서 고른 개수 (폴더 이름 옆 표시 · 아래 줄 숫자) + int countIn(String dir) => _marked.where((m) => samePath(vDirname(m), dir)).length; + + void _clearInside(String path) { + _marked.removeWhere((m) => isSameOrInside(m, path)); + _split.removeWhere((s) => isSameOrInside(s, path)); + if (anchor != null && isSameOrInside(anchor!, path)) anchor = null; + } + + /// 다른 표시 안에 든 표시는 뺀다 (폴더 하나로 안의 것까지 고른 것이므로) + void _normalize() { + final all = List.of(_marked); + _marked.removeWhere((m) => all.any((o) => !identical(o, m) && !samePath(o, m) && isSameOrInside(m, o))); + } +} diff --git a/test/selection_contract_test.dart b/test/selection_contract_test.dart new file mode 100644 index 0000000..ceb4e16 --- /dev/null +++ b/test/selection_contract_test.dart @@ -0,0 +1,155 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/core/selection.dart'; +import 'package:path/path.dart' as p; + +/// E1 약속 시험 (모듈 하나에 한 장): lib/core/selection.dart 맨 위 약속을 그대로 본다. +/// 기준은 wiki "파일 탐색기 선택 규칙 (사용자 지시 모음)" 규칙 2~6 · 8 과 관리자 결정 (10/22). +/// 폴더 안 목록은 실제 파일 시스템 (dart:io) 에서 읽는다. +void main() { + group('ClickPolicy (규칙 2 · 3 · 5 · 6)', () { + // (누르기, 종류, 선택 모드, 펼침, 목록, 다시, 기대) - 규칙 표 그대로 + const table = [ + (Press.tap, ItemKind.file, false, false, false, false, ClickAction.focus), // 규칙 2 한 번 = 선택 + (Press.doubleTap, ItemKind.file, false, false, false, false, ClickAction.open), // 규칙 2 두 번 = 실행 + (Press.longPress, ItemKind.file, false, false, false, false, ClickAction.menu), // 규칙 2 길게 = 메뉴 + (Press.secondary, ItemKind.folder, false, false, false, false, ClickAction.menu), + (Press.longPress, ItemKind.root, false, false, false, false, ClickAction.none), // 장치 · .. 는 메뉴 없음 + (Press.tap, ItemKind.folder, false, false, false, false, ClickAction.expand), // 규칙 3 펼치기 + (Press.tap, ItemKind.folder, false, true, false, false, ClickAction.collapse), // 규칙 3 접기 + (Press.tap, ItemKind.folder, false, true, false, true, ClickAction.none), // 두 번 누르기의 두 번째는 접지 않음 + (Press.tap, ItemKind.root, false, true, false, false, ClickAction.collapse), + (Press.tap, ItemKind.folder, false, false, true, false, ClickAction.open), // 목록의 폴더 = 들어가기 + (Press.tap, ItemKind.up, false, false, true, false, ClickAction.open), + (Press.tap, ItemKind.file, true, false, false, false, ClickAction.toggleMark), // 규칙 5 + (Press.tap, ItemKind.folder, true, false, false, false, ClickAction.toggleMark), // 규칙 6 + (Press.tap, ItemKind.root, true, false, false, false, ClickAction.expand), // 장치는 고르지 않음 + (Press.doubleTap, ItemKind.file, true, false, false, false, ClickAction.none), + (Press.longPress, ItemKind.file, true, false, false, false, ClickAction.menu), + ]; + for (final (press, kind, sel, exp, list, rep, want) in table) { + test('$press $kind 선택=$sel 펼침=$exp 목록=$list 다시=$rep → $want', () { + expect(ClickPolicy.decide(press, kind, selectMode: sel, expanded: exp, list: list, repeat: rep), want); + }); + } + }); + + group('Selection (규칙 5 · 6 · 8)', () { + late Directory tmp; + late String show, e1, e2, e3, sub, s1, notes; + var reads = 0; + Future> lister(String dir) async { + reads++; + return [for (final e in Directory(dir).listSync()) e.path]..sort(); + } + + setUp(() { + tmp = Directory.systemTemp.createTempSync('jj_sel_'); + show = p.join(tmp.path, 'Show'); + sub = p.join(show, 'Extras'); + Directory(sub).createSync(recursive: true); + e1 = p.join(show, 'ep01.mkv'); + e2 = p.join(show, 'ep02.mkv'); + e3 = p.join(show, 'ep03.mkv'); + s1 = p.join(sub, 'bonus.mkv'); + notes = p.join(tmp.path, 'notes.txt'); + for (final f in [e1, e2, e3, s1, notes]) { + File(f).writeAsStringSync('x'); + } + reads = 0; + }); + tearDown(() => tmp.deleteSync(recursive: true)); + + test('파일: 누르면 고름 · 다시 누르면 취소 (규칙 5)', () async { + final s = Selection(); + await s.toggle(e1, isDir: false, list: lister); + expect(s.marked, [e1]); + await s.toggle(e2, isDir: false, list: lister); + expect(s.marked, [e1, e2]); + await s.toggle(e1, isDir: false, list: lister); + expect(s.marked, [e2]); + expect(reads, 0); // 파일은 목록을 읽지 않음 + }); + + test('폴더: 자기 + 안 전체 · 다시 누르면 전체 취소 (규칙 6)', () async { + final s = Selection(); + await s.toggle(e1, isDir: false, list: lister); // 안에 따로 고른 것은 폴더 하나로 합쳐짐 + await s.toggle(show, isDir: true, list: lister); + expect(s.marked, [show]); + for (final x in [show, e1, e2, e3, sub, s1]) { + expect(s.isMarked(x), isTrue, reason: x); + } + expect(s.isMarked(notes), isFalse); + await s.toggle(show, isDir: true, list: lister); + expect(s.isEmpty, isTrue); + }); + + test('고른 폴더 안의 하나를 누르면 그것만 빠짐 (깊은 곳도)', () async { + final s = Selection(); + await s.toggle(show, isDir: true, list: lister); + await s.toggle(e1, isDir: false, list: lister); + expect(s.isMarked(e1), isFalse); + for (final x in [e2, e3, sub, s1]) { + expect(s.isMarked(x), isTrue, reason: x); + } + expect(s.isMarked(show), isFalse); + // 두 단계 아래 + final t = Selection(); + await t.toggle(show, isDir: true, list: lister); + await t.toggle(s1, isDir: false, list: lister); + expect(t.isMarked(s1), isFalse); + expect([e1, e2, e3].every(t.isMarked), isTrue); + }); + + test('하나를 뺀 폴더를 다시 누르면 그 폴더와 안 전체 취소 (관리자 결정 10/22)', () async { + final s = Selection(); + await s.toggle(show, isDir: true, list: lister); + await s.toggle(e1, isDir: false, list: lister); + await s.toggle(notes, isDir: false, list: lister); // 바깥 것은 남음 + await s.toggle(show, isDir: true, list: lister); + expect(s.marked, [notes]); + // 그 뒤 다시 누르면 처음처럼 전체 고름 + await s.toggle(show, isDir: true, list: lister); + expect(s.isMarked(e1), isTrue); + }); + + test('목록을 읽지 못하면 표시를 바꾸지 않고 던짐', () async { + final s = Selection(); + await s.toggle(show, isDir: true, list: lister); + Future> broken(String dir) async => throw FileSystemException('읽지 못함', dir); + await expectLater(s.toggle(e1, isDir: false, list: broken), throwsA(isA())); + expect(s.marked, [show]); + }); + + test('범위 · 모두 · 뒤집기 · 지운 것 빼기 · 대상', () async { + final s = Selection(); + final visible = [e1, e2, e3]; + await s.toggle(e1, isDir: false, list: lister); + s.range(visible, e3); + expect(s.marked, [e1, e2, e3]); + s.invert([e2]); + expect(s.marked, [e1, e3]); + s.forget(e1); + expect(s.marked, [e3]); + s.addAll([show]); + expect(s.marked, [show]); + expect(s.targets(notes), [show]); + s.clear(); + expect(s.targets(notes), [notes]); + expect(s.targets(null), isEmpty); + }); + + test('Rsync: 폴더만 · 하나만 · 다시 누르면 취소 (규칙 8)', () async { + final s = Selection(rsync: true); + await s.toggle(e1, isDir: false, list: lister); + expect(s.isEmpty, isTrue); + await s.toggle(show, isDir: true, list: lister); + await s.toggle(sub, isDir: true, list: lister); + expect(s.marked, [sub]); + expect(s.isMarked(s1), isFalse); // Rsync 는 고른 그 폴더만 + await s.toggle(sub, isDir: true, list: lister); + expect(s.isEmpty, isTrue); + }); + }); +}