import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:jj_mkvmaker/core/file_ops.dart'; import 'package:jj_mkvmaker/core/text_codec.dart'; import 'package:jj_mkvmaker/ui/explorer_grid.dart'; import 'package:jj_mkvmaker/ui/text_viewer.dart'; import 'package:path/path.dart' as p; /// X-plore 비교 g · h: 격자 보기 · 글 뷰어 void main() { late Directory tmp; setUp(() => tmp = Directory.systemTemp.createTempSync('jj_xptools_')); tearDown(() => tmp.deleteSync(recursive: true)); testWidgets('h 글 뷰어: CP949 를 알아보고 나눠 읽기 [더 읽기]', (t) async { final text = List.generate(40, (i) => '줄 $i 한글').join('\n'); final f = File(p.join(tmp.path, 'k.txt'))..writeAsBytesSync(encodeText(text, 'CP949').bytes); await t.pumpWidget(MaterialApp(home: TextViewerPage(path: f.path, chunk: 120))); for (var i = 0; i < 20 && find.textContaining('줄 0').evaluate().isEmpty; i++) { await t.runAsync(() => Future.delayed(const Duration(milliseconds: 30))); await t.pump(); } expect(find.text('줄 0 한글'), findsOneWidget); expect(find.text('더 읽기'), findsOneWidget, reason: '큰 파일은 나눠 읽음'); for (var k = 0; k < 10 && find.text('더 읽기').evaluate().isNotEmpty; k++) { await t.tap(find.text('더 읽기')); for (var i = 0; i < 5; i++) { await t.runAsync(() => Future.delayed(const Duration(milliseconds: 30))); await t.pump(); } } expect(find.text('더 읽기'), findsNothing, reason: '다 읽음'); expect(find.text('CP949 (EUC-KR)'), findsOneWidget); }); test('h 글 뷰어: 아주 긴 줄만 나눠 그린다 (줄 번호는 첫 조각에만, 이모지를 반으로 자르지 않음)', () { expect(viewerRows('a\nbb', 10), [(1, 'a'), (2, 'bb')]); final long = 'x' * 1000; final rows = viewerRows('first\n$long\nlast', 10); expect(rows.first, (1, 'first')); expect(rows.last, (3, 'last')); final mid = rows.sublist(1, rows.length - 1); expect(mid.map((r) => r.$2).join(), long, reason: '글은 그대로 (빠지거나 겹치지 않음)'); expect(mid.first.$1, 2); expect(mid.skip(1).every((r) => r.$1 == 0), isTrue); expect(mid.every((r) => r.$2.length <= 40), isTrue, reason: '화면 네 줄쯤씩'); expect(viewerRows('y' * 80, 10).length, 1, reason: '8배까지는 그대로 (화면에서 줄바꿈)'); // 나누는 자리에 두 단위 글자 (😀) 가 걸리면 앞으로 당긴다 final emoji = '${'z' * 39}😀${'z' * 100}'; final cut = viewerRows(emoji, 10); expect(cut.map((r) => r.$2).join(), emoji); expect(cut.every((r) => !r.$2.endsWith('\uD83D')), isTrue); }); testWidgets('h 글 뷰어: 줄바꿈 없는 큰 파일 (200KB 한 줄) 도 나눠 그려 멈추지 않는다', (t) async { final f = File(p.join(tmp.path, 'one.txt'))..writeAsStringSync('${'0123456789' * 20000}END'); t.view.physicalSize = const Size(412, 900); t.view.devicePixelRatio = 1; addTearDown(t.view.reset); await t.pumpWidget(MaterialApp(home: TextViewerPage(path: f.path))); for (var i = 0; i < 40 && find.textContaining('0123456789').evaluate().isEmpty; i++) { await t.runAsync(() => Future.delayed(const Duration(milliseconds: 30))); await t.pump(); } final texts = t.widgetList(find.textContaining('0123456789')).toList(); expect(texts.length, greaterThan(1), reason: '한 줄을 여러 조각으로'); expect(texts.every((x) => (x.data ?? '').length < 2000), isTrue, reason: '조각마다 짧게'); expect(find.text('1'), findsOneWidget, reason: '줄 번호는 첫 조각에만'); }); testWidgets('h 글 뷰어 (28 지적 · 6366a2f 회귀): 긴 줄을 나눠 그려도 Ctrl+A → Ctrl+C 는 줄 전체 · 막대 컨트롤러', (t) async { final line = List.generate(1109, (i) => 'abcdefghi${i % 10}').join(); final f = File(p.join(tmp.path, 'long.txt'))..writeAsStringSync('$line\nend'); t.view.physicalSize = const Size(412, 900); t.view.devicePixelRatio = 1; addTearDown(t.view.reset); String? copied; t.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, (call) async { if (call.method == 'Clipboard.setData') copied = (call.arguments as Map)['text'] as String?; return null; }); addTearDown(() => t.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, null)); await t.pumpWidget(MaterialApp(home: TextViewerPage(path: f.path))); for (var i = 0; i < 40 && find.textContaining('abcdefghi0').evaluate().isEmpty; i++) { await t.runAsync(() => Future.delayed(const Duration(milliseconds: 30))); await t.pump(); } expect(t.widgetList(find.textContaining('abcdefghi')).length, greaterThan(1), reason: '화면에는 나눠 그림'); await t.tapAt(const Offset(150, 140)); await t.pump(); await t.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); await t.sendKeyEvent(LogicalKeyboardKey.keyA); await t.sendKeyEvent(LogicalKeyboardKey.keyC); await t.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); await t.pump(); expect(copied, isNotNull); expect(copied!.length, line.length + 4, reason: '줄 전체 + "\nend" (보이는 조각만이 아님)'); expect(copied, '$line\nend'); // 막대에 컨트롤러가 붙어 있다 (데스크톱에서 ScrollPosition 없음 assert 가 나지 않게) final bar = t.widget(find.byType(Scrollbar)); expect(bar.controller, isNotNull); expect(bar.controller!.hasClients, isTrue); }); test('h 글 뷰어 (관리자 지시 10/19): 넓은 글자 (한글 · 한자) 는 두 칸으로 세어 네 줄 분량이 꽉 차게 나눈다', () { final line = List.generate(600, (i) => i.isEven ? '가' : 'a').join(); final rows = viewerRows(line, 10, wide: 2); expect(rows.map((r) => r.$2).join(), line); double units(String s) => s.runes.fold(0.0, (a, c) => a + (isWideChar(c) ? 2 : 1)); for (final r in rows.take(rows.length - 1)) { expect(units(r.$2), inInclusiveRange(39, 40), reason: '네 줄 (40칸) 에서 한 칸 넘게 비지 않음: ${r.$2}'); } expect(isWideChar('漢'.codeUnitAt(0)), isTrue); expect(isWideChar('ア'.codeUnitAt(0)), isTrue); expect(isWideChar('A'.codeUnitAt(0)), isTrue); expect(isWideChar('a'.codeUnitAt(0)), isFalse); // 비율이 2 가 아닌 글꼴 (잰 값) 도 그대로 final r15 = viewerRows('가' * 400, 10, wide: 1.5); expect(r15.first.$2.length, 26, reason: '40 / 1.5 = 26.6 → 26자'); }); testWidgets('h 글 뷰어: 한글이 섞인 긴 줄도 나눈 조각마다 화면 네 줄 (다섯째 반쪽 줄 없음)', (t) async { // 한 글자씩 바뀌어 어디서든 줄을 바꿀 수 있는 글 (영문 낱말이 이어지면 낱말 단위로 줄이 바뀌어 보통 글처럼 줄 끝이 조금 빈다) final line = List.generate(3000, (i) => i.isEven ? '한' : 'x').join(); final f = File(p.join(tmp.path, 'ko.txt'))..writeAsStringSync(line); t.view.physicalSize = const Size(412, 900); t.view.devicePixelRatio = 1; addTearDown(t.view.reset); await t.pumpWidget(MaterialApp(home: TextViewerPage(path: f.path))); for (var i = 0; i < 40 && find.textContaining('한x한x').evaluate().isEmpty; i++) { await t.runAsync(() => Future.delayed(const Duration(milliseconds: 30))); await t.pump(); } final pieces = find.textContaining('한x한x'); expect(pieces.evaluate().length, greaterThan(1)); final first = t.renderObject(find.descendant(of: pieces.first, matching: find.byType(RichText))); final txt = t.widget(pieces.first).data!; final lines = {for (final bx in first.getBoxesForSelection(TextSelection(baseOffset: 0, extentOffset: txt.length))) bx.top}.length; expect(lines, 4, reason: '네 줄 분량으로 나눔 (다섯째 반쪽 줄 없음)'); }); testWidgets('h 글 뷰어 (28 지적 10/19): 이모지가 든 긴 줄 - 재는 조각이 두 단위 글자 가운데서 끊기지 않음 (UTF-16 오류 없음)', (t) async { final line = List.generate(3000, (i) => 'abc😀').join(); final f = File(p.join(tmp.path, 'emoji.txt'))..writeAsStringSync('$line\nend'); for (final w in [412.0, 960.0]) { t.view.physicalSize = Size(w, 900); t.view.devicePixelRatio = 1; await t.pumpWidget(MaterialApp(home: TextViewerPage(key: ValueKey(w), path: f.path))); for (var i = 0; i < 40 && find.textContaining('abc😀abc').evaluate().isEmpty; i++) { await t.runAsync(() => Future.delayed(const Duration(milliseconds: 30))); await t.pump(); } expect(t.takeException(), isNull, reason: '폭 $w'); final texts = t.widgetList(find.textContaining('abc😀')).map((x) => x.data!).toList(); expect(texts, isNotEmpty); for (final s in texts) { expect((s.codeUnitAt(s.length - 1) & 0xFC00) == 0xD800, isFalse, reason: '조각이 반쪽 이모지로 끝나지 않음'); } } t.view.reset(); }); testWidgets('g 격자 보기: 그림 · 동영상만, 누르면 열기', (t) async { final png = File(p.join(tmp.path, 'a.png'))..writeAsBytesSync([0x89, 0x50]); final opened = []; final entries = [ FileEntry(png.path, isDir: false, size: 2, modified: DateTime(2026)), FileEntry(p.join(tmp.path, 'b.mkv'), isDir: false, size: 2, modified: DateTime(2026)), FileEntry(p.join(tmp.path, 'c.txt'), isDir: false, size: 2, modified: DateTime(2026)), FileEntry(p.join(tmp.path, 'dir'), isDir: true, modified: DateTime(2026)), ]; expect(ExplorerGridPage.pick(entries, const ['png']).map((e) => e.name), ['a.png', 'b.mkv']); await t.pumpWidget(MaterialApp( home: ExplorerGridPage( title: 'T', entries: entries, imageExts: const ['png'], videoThumb: (_) async => null, onOpen: (e) async => opened.add(e.name), ), )); await t.pump(); expect(find.text('a.png'), findsOneWidget); expect(find.text('c.txt'), findsNothing); await t.tap(find.text('b.mkv')); expect(opened, ['b.mkv']); }); }