import 'dart:io'; import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:jj_mkvmaker/app/app_controller.dart'; import 'package:jj_mkvmaker/core/sqlite_reader.dart'; import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; import 'package:jj_mkvmaker/services/platform_services.dart'; import 'package:jj_mkvmaker/ui/sqlite_viewer.dart'; import 'package:path/path.dart' as p; /// X-plore 비교 5-1 ④ (관리자 지시 10/21): SQLite 읽기 전용 - 시험 파일은 진짜 SQLite (python sqlite3) 가 만든 것 /// (tool/fixtures/make_sqlite_fixtures.py → test/fixtures/sqlite) void main() { String fx(String name) => p.join('test', 'fixtures', 'sqlite', name); test('basic.db: 테이블 · 열 이름 (INTEGER PRIMARY KEY · 따옴표 · 제약 줄 뺌) · WITHOUT ROWID 표시', () async { final db = await SqliteFile.open(fx('basic.db')); addTearDown(db.close); final ts = await db.tables(); expect(ts.map((t) => t.name), ['people', 'odd name', 'kv']); expect(ts[0].displayColumns, ['id', 'name', 'age', 'score', 'photo', 'note']); expect(ts[1].displayColumns, ['a b', 'c', 'd', 'e'], reason: '따옴표 · [ ] · ` 이름, CONSTRAINT 줄은 빼고, DEFAULT (1+2) 의 쉼표에 속지 않음'); expect(ts[2].withoutRowid, isTrue); await expectLater(db.rows(ts[2]), throwsA(isA())); }); test('basic.db: 값 - 한글 · 일본어 · NULL · 음수 · 64비트 정수 · 실수 · BLOB · 넘친 페이지의 긴 글 · rowid = INTEGER PRIMARY KEY', () async { final db = await SqliteFile.open(fx('basic.db')); addTearDown(db.close); final people = (await db.tables()).first; final rows = await db.rows(people); expect(rows.map((r) => r.rowid), [1, 2, 5, 7]); expect(rows[0].values.take(4), [1, '홍길동', 30, 91.5]); expect(rows[0].values[4], Uint8List.fromList([0, 1, 2])); expect(rows[0].values[5], isNull); expect(rows[1].values.sublist(2, 4), [-5, -0.25]); expect(rows[1].values[5], 'x' * 5000, reason: '넘친 페이지 (페이지 1024 바이트) 를 이어 읽음'); expect(rows[2].values.sublist(0, 4), [5, '日本語', 9007199254740993, 1e100]); expect(rows[2].values[4], Uint8List(0)); expect(rows[3].values.sublist(1, 3), [null, 0]); expect(rows[3].values[5], '줄\n바꿈'); expect(await db.count(people), 4); final odd = (await db.tables())[1]; expect((await db.rows(odd)).single.values, ['v', 1, 2, 3]); }); test('big.db: 2000 행 (안쪽 페이지 여러 단계) - 차례대로 · 건너뛰기 · 개수', () async { final db = await SqliteFile.open(fx('big.db')); addTearDown(db.close); final t = (await db.tables()).single; expect(await db.count(t), 2000); final first = await db.rows(t, limit: 3); expect(first.map((r) => r.values[1]), ['row 0001 y', 'row 0002 yy', 'row 0003 yyy']); final page = await db.rows(t, offset: 1500, limit: 2); expect(page.map((r) => r.rowid), [1501, 1502]); final all = await db.rows(t, limit: 5000); expect(all.length, 2000); expect([for (final r in all) r.rowid], [for (var i = 1; i <= 2000; i++) i], reason: 'rowid 차례'); }); test('utf16.db: UTF-16LE 글', () async { final db = await SqliteFile.open(fx('utf16.db')); addTearDown(db.close); final t = (await db.tables()).single; expect(db.encoding, 2); expect((await db.rows(t)).map((r) => r.values.single), ['가나다', 'abc']); }); test('SQLite 가 아닌 파일 · 잘린 파일은 SqliteFormatException (파일을 바꾸지 않음)', () async { final tmp = Directory.systemTemp.createTempSync('jj_sqlite_'); addTearDown(() => tmp.deleteSync(recursive: true)); final txt = File(p.join(tmp.path, 'a.db'))..writeAsStringSync('hello'); await expectLater(SqliteFile.open(txt.path), throwsA(isA())); // 머리만 남기고 자른 파일: 열 수는 있어도 페이지를 읽을 때 오류 final cut = File(p.join(tmp.path, 'cut.db'))..writeAsBytesSync(File(fx('big.db')).readAsBytesSync().sublist(0, 1024)); final db = await SqliteFile.open(cut.path); addTearDown(db.close); final t = (await db.tables()).single; await expectLater(db.rows(t, limit: 5000), throwsA(isA())); final before = File(fx('basic.db')).readAsBytesSync(); final b = await SqliteFile.open(fx('basic.db')); await b.rows((await b.tables()).first); await b.close(); expect(File(fx('basic.db')).readAsBytesSync(), before, reason: '읽기만'); }); Future openPage(WidgetTester tester, String path, bool Function() ready) async { tester.view.physicalSize = const Size(1200, 900); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); await tester.pumpWidget(MaterialApp(home: SqliteViewerPage(c: c, path: path))); for (var i = 0; i < 500 && !ready(); i++) { await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 30))); await tester.pump(); } } testWidgets('보기 화면: 첫 테이블 · 행 수 · 값 (NULL · BLOB) · 칸을 누르면 전체 값 · 다른 테이블 고르기', (tester) async { await openPage(tester, fx('basic.db'), () => find.text('홍길동').evaluate().isNotEmpty); expect(find.text('홍길동'), findsOneWidget); expect(find.text('4행'), findsOneWidget); expect(find.text('NULL'), findsWidgets); expect(find.text('(BLOB 3바이트)'), findsOneWidget); expect(find.textContaining('읽기 전용'), findsOneWidget); await tester.tap(find.text('홍길동')); await tester.pumpAndSettle(); expect(find.widgetWithText(AlertDialog, 'name'), findsOneWidget); await tester.tap(find.text('닫기')); await tester.pumpAndSettle(); // WITHOUT ROWID 테이블은 안내 await tester.tap(find.byType(DropdownButton)); await tester.pumpAndSettle(); await tester.tap(find.text('kv').last); for (var i = 0; i < 20; i++) { await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 20))); await tester.pump(); } expect(find.text('WITHOUT ROWID 테이블은 아직 볼 수 없습니다'), findsOneWidget); await tester.pumpWidget(const SizedBox()); }); testWidgets('보기 화면: 2000 행은 200 씩 [더 보기] · SQLite 가 아니면 알림', (tester) async { await openPage(tester, fx('big.db'), () => find.textContaining('더 보기').evaluate().isNotEmpty); expect(find.text('더 보기 (200 / 2000)'), findsOneWidget); await tester.tap(find.text('더 보기 (200 / 2000)')); for (var i = 0; i < 50 && find.text('더 보기 (400 / 2000)').evaluate().isEmpty; i++) { await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 20))); await tester.pump(); } expect(find.text('더 보기 (400 / 2000)'), findsOneWidget); await tester.pumpWidget(const SizedBox()); final tmp = Directory.systemTemp.createTempSync('jj_sqlv_'); addTearDown(() => tmp.deleteSync(recursive: true)); final txt = File(p.join(tmp.path, 'x.db'))..writeAsStringSync('not sqlite'); await openPage(tester, txt.path, () => find.text('SQLite 파일이 아니거나 깨졌습니다').evaluate().isNotEmpty); expect(find.text('SQLite 파일이 아니거나 깨졌습니다'), findsOneWidget); await tester.pumpWidget(const SizedBox()); }); }