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 대신) - 부른 범위를 적어 둔다. /// 문서는 앱과 같은 [QueuedHwpDoc] (줄 세우기 · 시간 제한 · 멈춤) 위의 가짜 C 함수 ([_Native]) class _Engine implements HwpPdfEngine { final int pages; final calls = <(int, int)>[]; /// 있으면 바꾸기가 이것을 기다린다 (멈춤 · 닫기를 받으면 바로 끝남 - Rust 가 쪽마다 멈춤 표시를 보는 것처럼) Completer? hold; var closed = false; final cancelled = {}; var cancelledAll = false; _Engine(this.pages); @override Future open(String path, {String? password}) async => QueuedHwpDoc(_Native(this)); } class _Native implements HwpNative { final _Engine e; _Native(this.e); @override int get pageCount => e.pages; @override Future<(int, String?)> export(int req, int from, int to, String fontDirs, String out) async { e.calls.add((from, to)); final h = e.hold; if (h != null) await h.future; if (e.cancelledAll || e.cancelled.contains(req)) return (1, 'cancelled'); File(out).writeAsStringSync('$from-$to'); return (0, null); } void _wake() { final h = e.hold; if (h != null && !h.isCompleted) h.complete(); } @override void cancel(int req) { e.cancelled.add(req); _wake(); } @override void cancelAll() { e.cancelledAll = true; _wake(); } @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.cancelled, {1}, reason: '시간 제한을 넘기면 그 요청의 변환도 멈춘다 (기다림만 끊지 않음)'); expect(s.openChunks, isNot(contains(0)), reason: '실패한 묶음은 기억하지 않음 - [다시 시도] 가 다시 만든다'); 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.cancelledAll, isTrue, reason: '닫으면 바꾸던 것을 멈춘다'); expect(e.closed, isTrue); 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); } }); group('QueuedHwpDoc (28 지적: 시간 제한 · 멈춤은 요청마다)', () { test('앞 묶음이 오래 돌아도, 줄에서 기다린 뒤 묶음의 시간 제한은 제 시작부터 잰다', () async { final n = _Gated(); final d = QueuedHwpDoc(n); final a = d.exportPdf(0, 5, 'a', timeout: const Duration(seconds: 30)); // 오래 도는 앞 묶음 // 흔들림 (10/21 전체 실행): 제한이 300ms 면 무거울 때 [release] 까지의 틈이 그것을 넘었다 - 제한 1.5초 · 줄에서 2초 기다림 final b = d.exportPdf(5, 10, 'b', timeout: const Duration(milliseconds: 1500)); await Future.delayed(const Duration(milliseconds: 2000)); // 뒤 묶음의 제한보다 오래 줄에서 기다림 expect(n.started, [1]); n.release(1); await a; await Future.delayed(const Duration(milliseconds: 50)); expect(n.started, [1, 2]); n.release(2); await b; // 시간 제한 없이 끝남 expect(n.cancelled, isEmpty); }); test('한 요청이 시간을 넘기면 그 요청만 멈추고, 멈춘 것이 끝난 뒤 다음 것이 돈다', () async { final n = _Gated(); final d = QueuedHwpDoc(n); Object? err; final a = d.exportPdf(0, 5, 'a', timeout: const Duration(milliseconds: 200)).catchError((Object e) => err = e); final b = d.exportPdf(5, 10, 'b'); await a; expect(err, isA().having((e) => e.kind, 'kind', HwpErrorKind.timeout)); expect(n.cancelled, {1}, reason: '다른 요청 (2) 은 멈추지 않음'); await Future.delayed(const Duration(milliseconds: 50)); expect(n.started, [1, 2], reason: '멈춘 1 이 끝난 뒤에 2 가 시작'); n.release(2); await b; }); test('닫으면 도는 것은 멈추고 줄에 선 것은 시작하지 않는다 (닫기 요청이 지워지지 않음)', () async { final n = _Gated(); final d = QueuedHwpDoc(n); final a = d.exportPdf(0, 5, 'a').then((_) => null, onError: (Object e) => e); final b = d.exportPdf(5, 10, 'b').then((_) => null, onError: (Object e) => e); await Future.delayed(const Duration(milliseconds: 50)); await d.close(); expect(n.all, isTrue); expect(n.started, [1], reason: '줄에 선 2 는 시작하지 않음'); expect(n.closed, isTrue); expect(await a, isA().having((e) => e.kind, 'kind', HwpErrorKind.closed)); expect(await b, isA().having((e) => e.kind, 'kind', HwpErrorKind.closed)); expect(() => d.exportPdf(0, 1, 'c'), throwsA(isA()), reason: '닫은 뒤의 요청'); }); }); } /// 요청마다 문이 있는 가짜 C 함수 - 멈춤 · 닫기를 받으면 문이 열리고 (cancelled) 로 끝난다 class _Gated implements HwpNative { final started = []; final gates = >{}; final cancelled = {}; var all = false; var closed = false; @override int get pageCount => 10; Completer _gate(int req) => gates.putIfAbsent(req, Completer.new); void release(int req) { if (!_gate(req).isCompleted) _gate(req).complete(); } @override Future<(int, String?)> export(int req, int from, int to, String fontDirs, String out) async { started.add(req); await _gate(req).future; return all || cancelled.contains(req) ? (1, 'cancelled') : (0, null); } @override void cancel(int req) { cancelled.add(req); release(req); } @override void cancelAll() { all = true; for (final r in [...gates.keys, ...started]) { release(r); } } @override Future close() async => closed = true; }