import 'dart:async'; import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:jj_mkvmaker/core/hwp_source.dart'; import 'package:jj_mkvmaker/core/reader_sources.dart'; import 'package:path/path.dart' as p; /// 쪽 범위를 글로 적은 파일을 "PDF" 로 만드는 가짜 엔진 (rhwp 대신) - 부른 범위를 적어 둔다 class _Engine implements HwpPdfEngine { final int pages; final calls = <(int, int)>[]; Completer? hold; var closed = false; var cancels = 0; _Engine(this.pages); @override Future open(String path, {String? password}) async => _Doc(this); } class _Doc implements HwpPdfDoc { final _Engine e; _Doc(this.e); @override int get pageCount => e.pages; @override Future exportPdf(int from, int to, String out, {List fontDirs = const []}) async { e.calls.add((from, to)); final h = e.hold; if (h != null) await h.future; File(out).writeAsStringSync('$from-$to'); } @override void cancel() => e.cancels++; @override Future close() async => e.closed = true; } /// 파일을 읽어 쪽 수를 아는 가짜 묶음 (PDFium 없이) - 연 것 · 닫은 것을 적어 둔다 class _Opener { final opened = []; final disposed = []; Completer? renderHold; Future call(String path) async { opened.add(p.basename(path)); final parts = File(path).readAsStringSync().split('-').map(int.parse).toList(); return _Chunk(this, p.basename(path), parts[1] - parts[0]); } } class _Chunk implements HwpChunk { final _Opener o; final String name; @override final int pageCount; _Chunk(this.o, this.name, this.pageCount); @override Future render(int index, {required int maxWidth}) async { expect(index, lessThan(pageCount)); final h = o.renderHold; if (h != null) await h.future; return const ReaderImage(); } @override Future dispose() async => o.disposed.add(name); } /// 관리자 지시 (10/20, HWP 2단계): 보기 원본 HwpSource 의 흐름 - 묶음 나누기 · 미리 만들기 · 열어 두는 묶음 상한 (메모리) · /// 그리는 묶음은 닫지 않음 · 닫은 묶음은 다시 바꾸지 않고 파일만 · 시간 제한 → 이유 · 다시 시도 · 닫기 void main() { late Directory dir; late File doc; setUp(() { dir = Directory.systemTemp.createTempSync('jj_hwpunit_'); doc = File(p.join(dir.path, 'a.hwp'))..writeAsStringSync('x'); }); tearDown(() { if (dir.existsSync()) dir.deleteSync(recursive: true); }); Future settle() => Future.delayed(const Duration(milliseconds: 50)); test('보는 쪽의 묶음 + 다음 묶음만 · 열어 둔 묶음은 3개까지 (오래된 것부터 닫음) · 다시 보면 파일만 연다', () async { final e = _Engine(40), o = _Opener(); final s = await HwpSource.open(doc.path, engine: e, tempDir: dir.path, chunk: 5, maxOpenChunks: 3, opener: o.call); expect(s.length, 40); await s.load(0, maxWidth: 100); await settle(); expect(e.calls, [(0, 5), (5, 10)]); expect(s.openChunks, [0, 1]); await s.load(12, maxWidth: 100); // 묶음 2 + 미리 3 → 4개 → 0 을 닫음 await settle(); expect(e.calls, [(0, 5), (5, 10), (10, 15), (15, 20)]); expect(s.openChunks, [1, 2, 3]); expect(o.disposed, ['p0.pdf']); // 닫은 묶음 0 을 다시: 바꾸지 않고 (calls 그대로) 파일만 연다 await s.load(1, maxWidth: 100); await settle(); expect(e.calls.where((c) => c == (0, 5)), hasLength(1)); expect(o.opened.where((n) => n == 'p0.pdf'), hasLength(2)); expect(s.openChunks.length, lessThanOrEqualTo(3)); expect(s.madeChunks, containsAll([0, 1, 2, 3])); // 끝 묶음은 남은 쪽만 await s.load(39, maxWidth: 100); expect(e.calls.last, (35, 40)); await s.dispose(); expect(e.closed, isTrue); expect(s.work.existsSync(), isFalse, reason: '닫으면 만든 PDF 들을 지움'); }); test('그리고 있는 묶음은 상한을 넘어도 닫지 않고, 다 그린 뒤에 닫는다', () async { final e = _Engine(40), o = _Opener(); final s = await HwpSource.open(doc.path, engine: e, tempDir: dir.path, chunk: 5, maxOpenChunks: 2, opener: o.call); final hold = Completer(); o.renderHold = hold; final drawing = s.load(2, maxWidth: 100); // 묶음 0 을 그리는 중 await settle(); o.renderHold = null; await s.load(20, maxWidth: 100); // 묶음 4 (+ 5) await s.load(30, maxWidth: 100); // 묶음 6 (+ 7) await settle(); expect(o.disposed, isNot(contains('p0.pdf')), reason: '그리는 중'); expect(s.openChunks, contains(0)); hold.complete(); await drawing; await s.load(35, maxWidth: 100); await settle(); expect(o.disposed, contains('p0.pdf'), reason: '다 그린 뒤에는 상한에 맞춰 닫힘'); expect(s.openChunks.length, lessThanOrEqualTo(2)); await s.dispose(); }); test('시간 제한을 넘기면 쪽 범위 · 초가 든 이유, 다시 부르면 다시 만든다', () async { final e = _Engine(8)..hold = Completer(); final o = _Opener(); final s = await HwpSource.open(doc.path, engine: e, tempDir: dir.path, chunk: 5, timeout: const Duration(milliseconds: 200), opener: o.call); Object? err; try { await s.load(0, maxWidth: 100); } catch (x) { err = x; } expect(err, isA()); expect((err! as HwpPdfException).kind, HwpErrorKind.timeout); expect('$err', allOf(contains('1~5'), contains('0초'))); expect(e.cancels, 1, reason: '시간 제한을 넘기면 변환도 멈춘다 (기다림만 끊지 않음)'); expect(s.openChunks, isNot(contains(0)), reason: '실패한 묶음은 기억하지 않음 - [다시 시도] 가 다시 만든다'); e.hold!.complete(); e.hold = null; await s.load(0, maxWidth: 100); expect(e.calls.where((c) => c == (0, 5)).length, greaterThanOrEqualTo(2)); await s.dispose(); }); test('닫으면 만드는 중인 묶음은 끝난 뒤 스스로 닫히고 더 만들지 않는다', () async { final e = _Engine(10)..hold = Completer(); final o = _Opener(); final s = await HwpSource.open(doc.path, engine: e, tempDir: dir.path, chunk: 5, opener: o.call); final pending = s.load(0, maxWidth: 100).then((_) => null, onError: (Object x) => x); await settle(); await s.dispose(); // 기다리지 않고 바로 끝난다 expect(e.cancels, 1, reason: '닫으면 바꾸던 것을 멈춘다'); e.hold!.complete(); final r = await pending; expect(r, isA(), reason: '닫힌 문서'); expect(o.opened, isEmpty, reason: '닫은 뒤에는 PDF 를 열지 않음'); }); test('앱이 켜질 때 남은 HWP 작업 폴더 (jj_hwp_) 만 지운다 - 막 생긴 것 · 다른 이름은 남김', () { final old = Directory(p.join(dir.path, 'jj_hwp_1'))..createSync(); File(p.join(old.path, 'p0.pdf')).writeAsStringSync('x'); final fresh = Directory(p.join(dir.path, 'jj_hwp_2'))..createSync(); final other = Directory(p.join(dir.path, 'jj_hwpx_keep'))..createSync(); final file = File(p.join(dir.path, 'jj_hwp_file'))..writeAsStringSync('x'); // 5분 뒤로 보면 old · fresh 둘 다 1분이 지났다 - fresh 는 지금 기준으로는 남는다 expect(removeStaleHwpWork(dir.path), isEmpty, reason: '모두 막 생김'); final gone = removeStaleHwpWork(dir.path, now: DateTime.now().add(const Duration(minutes: 5))); expect(gone..sort(), ['jj_hwp_1', 'jj_hwp_2']); expect(old.existsSync() || fresh.existsSync(), isFalse); expect(other.existsSync(), isTrue, reason: '이름이 jj_hwp_ 로 시작하지 않음 (jj_hwpx_…)'); expect(file.existsSync(), isTrue, reason: '폴더만'); }); test('변환기 오류 코드 → 종류 · 화면 글 (tool/rhwp_ffi 의 JJ_RHWP_E_* 순서), 원문은 detail 에만', () { expect([for (var c = 1; c <= 9; c++) hwpErrorKindOf(c)], [ HwpErrorKind.cancelled, HwpErrorKind.read, HwpErrorKind.invalid, HwpErrorKind.passwordRequired, HwpErrorKind.passwordWrong, HwpErrorKind.range, HwpErrorKind.render, HwpErrorKind.write, HwpErrorKind.internal, // ]); expect(hwpErrorKindOf(0), HwpErrorKind.internal); expect(hwpErrorKindOf(42), HwpErrorKind.internal); const e = HwpPdfException(HwpErrorKind.passwordRequired, '비밀번호가 필요한 암호 문서입니다 (parse_document_with_password …)'); expect('$e', '암호가 걸린 문서입니다', reason: '화면에는 원문 대신 번역 글'); expect(e.detail, contains('parse_document_with_password')); // 원본 lib.rs 의 코드 값과 같은지 (순서가 바뀌면 화면 글이 틀린다) final rs = File('tool/rhwp_ffi/src/lib.rs').readAsStringSync(); const names = ['CANCELLED', 'READ', 'INVALID', 'PASSWORD_REQUIRED', 'PASSWORD_WRONG', 'RANGE', 'RENDER', 'WRITE', 'INTERNAL']; for (final (i, n) in names.indexed) { expect(rs, contains('pub const JJ_RHWP_E_$n: c_int = ${i + 1};'), reason: n); } }); }