import 'dart:convert'; import 'dart:io'; import 'package:archive/archive_io.dart'; import 'package:crypto/crypto.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:jj_mkvmaker/app/ai_local.dart'; import 'package:jj_mkvmaker/app/settings.dart'; import 'package:jj_mkvmaker/core/ai_catalog.dart'; import 'package:jj_mkvmaker/core/sd_cli.dart'; import 'package:jj_mkvmaker/services/image_ai.dart'; import 'package:jj_mkvmaker/services/secret_store.dart'; import 'package:path/path.dart' as p; /// 1×1 PNG final _png = base64Decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='); void main() { group('123: sd-cli 인수 · 진행 읽기', () { const m = SdModelPaths(model: 'm.safetensors', loraDir: 'lora', loraName: 'lcm-lora-sdv1-5', taesd: 'taesd.safetensors'); test('글 → 그림: LCM-LoRA · f16 · TAESD · 장치 · 여러 장', () { final a = sdCliArgs(const ImageGenRequest(prompt: 'a cat', negative: 'blurry', seed: 7, count: 2), m, out: 'o_%d.png', backend: 'vulkan0', threads: 8); expect(a.join(' '), contains('-m m.safetensors --type f16 --lora-model-dir lora --taesd taesd.safetensors')); expect(a[a.indexOf('-p') + 1], 'a cat'); expect(a[a.indexOf('-n') + 1], 'blurry'); for (final pair in [ ['--sampling-method', 'lcm'], ['--seed', '7'], ['-b', '2'], ['--backend', 'vulkan0'], ['-t', '8'], ['-o', 'o_%d.png'], ['--cfg-scale', '1.0'], ]) { expect(a[a.indexOf(pair[0]) + 1], pair[1], reason: pair[0]); } expect(a, isNot(contains('-i'))); }); test('그림 → 그림 · LoRA 없이 · 빼고 싶은 것 없으면 -n 없음', () { final a = sdCliArgs(const ImageGenRequest(prompt: 'x', initImage: 'in.png', strength: 0.4), const SdModelPaths(model: 'm'), out: 'o.png'); expect(a[a.indexOf('-i') + 1], 'in.png'); expect(a[a.indexOf('--strength') + 1], '0.4'); expect(a[a.indexOf('-p') + 1], 'x'); expect(a, isNot(contains('-n'))); expect(a, isNot(contains('--sampling-method'))); }); test('해상도 올리기 인수 (121)', () { expect(sdUpscaleArgs(model: 'esr.pth', input: 'a.png', out: 'b.png', repeats: 2).join(' '), '-M upscale --upscale-model esr.pth -i a.png --upscale-repeats 2 -o b.png'); }); test('진행: 읽기 · 그리기 (단계당 초) · 디코드 · 끝', () { final load = parseSdProgress(' |####################### | 90/196 - 628.27MB/s\x1b[K')!; expect((load.phase, load.step, load.steps), ('load', 90, 196)); expect(load.overall, closeTo(0.1 * 90 / 196, 1e-9)); final s = parseSdProgress(' |=========================> | 2/4 - 25.11s/it')!; expect((s.phase, s.step, s.steps, s.secondsPerStep), ('sample', 2, 4, 25.11)); expect(s.overall, closeTo(0.5, 1e-9)); expect(parseSdProgress('|===> | 1/4 - 2.00it/s')!.secondsPerStep, 0.5); expect(parseSdProgress('[I] decode_first_stage completed, taking 2.56s')!.phase, 'decode'); expect(parseSdProgress('[I] generate_image completed in 49.03s')!.overall, 1); expect(parseSdProgress('[I] loading model from x'), isNull); }); test('장치 목록 읽기', () { expect(parseSdDevices('ggml_vulkan: 1 = ...\nVulkan0\tNVIDIA GeForce MX150\nVulkan1\tIntel UHD\nCPU\tIntel i7\n'), [ ('vulkan0', 'NVIDIA GeForce MX150'), ('vulkan1', 'Intel UHD'), ('cpu', 'Intel i7'), ]); }); test('다시 만들 수 있게 남기는 json (시드 · 모델 · 프롬프트)', () { const r = ImageGenRequest(prompt: '고양이', negative: 'x', width: 768, height: 512, steps: 6, seed: -1, initImage: 'a.png', strength: 0.3); final back = ImageGenRequest.fromJson(jsonDecode(r.encode(engine: '이 기기', actualSeed: 42)) as Map); expect((back.prompt, back.negative, back.width, back.height, back.steps, back.seed, back.initImage, back.strength), ('고양이', 'x', 768, 512, 6, 42, 'a.png', 0.3)); expect(resolveSeed(5), 5); expect(resolveSeed(-1), inInclusiveRange(0, (1 << 31) - 1)); }); }); group('123: 받는 파일 목록', () { test('공식 배포처 · SHA-256 고정 · 비상업 라이선스 없음 · CUDA 는 NVIDIA 때만', () { for (final f in aiCatalog) { expect(f.url, startsWith('https://'), reason: f.id); expect(RegExp(r'^[0-9a-f]{64}$').hasMatch(f.sha256), isTrue, reason: f.id); expect(f.size, greaterThan(0), reason: f.id); expect(f.license.toUpperCase(), isNot(contains('NC')), reason: f.id); expect(f.licenseUrl, startsWith('https://')); } expect(aiFile('sd15').terms, contains('OpenRAIL-M')); expect(aiFile('engine-cuda').needs, 'nvidia'); expect(aiFile('engine-cudart').needs, 'nvidia'); expect(aiFile('engine-vulkan').needs, isNull); expect(aiLicenseTexts().map((e) => e.$1), contains('stable-diffusion.cpp')); expect(aiLicenseTexts().firstWhere((e) => e.$1 == 'Stable Diffusion v1.5').$2, contains('부속서 A')); }); }); group('123: 받기 (SHA-256 확인 · 풀기)', () { late Directory tmp; late HttpServer server; final files = >{}; setUp(() async { tmp = Directory.systemTemp.createTempSync('jj_ai_store_'); server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); server.listen((req) async { final b = files[req.uri.path]; if (b == null) { req.response.statusCode = 404; } else { req.response.add(b); } await req.response.close(); }); }); tearDown(() async { await server.close(force: true); tmp.deleteSync(recursive: true); }); AiFile file(String id, String path, List body, {String kind = 'model', String? sha}) { files[path] = body; return AiFile( id: id, name: id, url: 'http://127.0.0.1:${server.port}$path', sha256: sha ?? sha256.convert(body).toString(), size: body.length, kind: kind, license: 'MIT', licenseUrl: 'https://x'); } test('맞는 파일은 받아 둠 · SHA-256 이 다르면 지우고 알림 · 지우기', () async { final store = AiStore(tmp.path); final good = file('m1', '/m1.safetensors', List.filled(5000, 7)); await store.installFile(good); expect(store.isFileInstalled(good), isTrue); expect(File(store.pathOf(good)).lengthSync(), 5000); final bad = file('m2', '/m2.safetensors', [1, 2, 3], sha: '0' * 64); await expectLater(store.installFile(bad), throwsA(isA())); expect(store.isFileInstalled(bad), isFalse); expect(tmp.listSync(recursive: true).whereType().where((f) => f.path.contains('.part')), isEmpty); }); test('실행 파일 zip 은 풀어 둔다', () async { final store = AiStore(tmp.path); final arc = Archive()..addFile(ArchiveFile.bytes('sd-cli.exe', utf8.encode('fake'))); final zip = ZipEncoder().encode(arc); final eng = file('engine-x', '/e.zip', zip, kind: 'engine'); await store.installFile(eng); expect(File(p.join(store.pathOf(eng), 'sd-cli.exe')).existsSync(), isTrue); }); }); group('123: 사용자가 추가한 서버 · 서비스', () { late HttpServer server; final seen = []; Map? lastBody; setUp(() async { seen.clear(); server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); var polls = 0; server.listen((req) async { seen.add('${req.method} ${req.uri.path} ${req.headers.value('authorization') ?? ''}'); final text = await utf8.decoder.bind(req).join(); if (text.isNotEmpty && req.headers.contentType?.mimeType == 'application/json') lastBody = jsonDecode(text) as Map; final res = req.response..headers.contentType = ContentType.json; switch (req.uri.path) { case '/sdapi/v1/txt2img' || '/sdapi/v1/img2img': res.write(jsonEncode({'images': [base64Encode(_png), base64Encode(_png)]})); case '/v1/images/generations': res.write(jsonEncode({'data': [{'b64_json': base64Encode(_png)}]})); case '/prompt': res.write(jsonEncode({'prompt_id': 'p1'})); case '/history/p1': polls++; res.write(polls < 2 ? '{}' : jsonEncode({'p1': {'outputs': {'9': {'images': [{'filename': 'a.png', 'subfolder': '', 'type': 'output'}]}}}})); case '/view': res.headers.contentType = ContentType('image', 'png'); res.add(_png); default: res.statusCode = 404; } await res.close(); }); }); tearDown(() => server.close(force: true)); Future> run(AiService s, ImageGenRequest r) async { final dir = Directory.systemTemp.createTempSync('jj_ai_remote_'); addTearDown(() => dir.deleteSync(recursive: true)); final made = await RemoteImageEngine(s).generate(r, outDir: dir.path, baseName: 'ai'); for (final g in made) { expect(File(g.path).readAsBytesSync(), _png); } return made; } test('Automatic1111: 글 → 그림 · 시드 · 아이디:비밀번호는 Basic', () async { final s = AiService(id: 'a', name: 'GPU PC', url: 'http://127.0.0.1:${server.port}/', kind: 'a1111', apiKey: 'u:p'); final made = await run(s, const ImageGenRequest(prompt: 'cat', seed: 10, count: 2)); expect(made.map((g) => g.seed), [10, 11]); expect(seen.single, startsWith('POST /sdapi/v1/txt2img Basic ')); expect(lastBody!['seed'], 10); expect(lastBody!['batch_size'], 2); expect(RemoteImageEngine(s).leavesDevice, isTrue); }); test('OpenAI 형식: Bearer 키 · 크기', () async { final s = AiService(id: 'o', name: 'svc', url: 'http://127.0.0.1:${server.port}', kind: 'openai', model: 'm', apiKey: 'sk-test'); await run(s, const ImageGenRequest(prompt: 'cat', width: 512, height: 768)); expect(seen.single, 'POST /v1/images/generations Bearer sk-test'); expect(lastBody!['size'], '512x768'); expect(lastBody!['model'], 'm'); }); test('ComfyUI: 작업 흐름을 보내고 끝날 때까지 기다려 결과를 받는다', () async { final s = AiService(id: 'c', name: 'comfy', url: 'http://127.0.0.1:${server.port}', kind: 'comfy', model: 'sd15.safetensors'); final made = await run(s, const ImageGenRequest(prompt: 'cat', seed: 3)); expect(made, hasLength(1)); final flow = lastBody!['prompt'] as Map; expect((flow['4'] as Map)['inputs'], {'ckpt_name': 'sd15.safetensors'}); expect(((flow['3'] as Map)['inputs'] as Map)['seed'], 3); expect(seen.where((x) => x.startsWith('GET /history')).length, greaterThanOrEqualTo(2)); }); test('설정 파일에는 API 키를 쓰지 않고 안전 저장소에 (124)', () async { final dir = Directory.systemTemp.createTempSync('jj_ai_key_'); addTearDown(() => dir.deleteSync(recursive: true)); final secrets = MemorySecretStore(); final st = SettingsStore(p.join(dir.path, 'settings.json'), secrets); final s = await st.load(); s.aiServices = [const AiService(id: 'x', name: 'n', url: 'http://h', kind: 'a1111', apiKey: 'sk-secret')]; await st.save(s); expect(File(p.join(dir.path, 'settings.json')).readAsStringSync(), isNot(contains('sk-secret'))); expect(secrets.values['ai.key.x'], 'sk-secret'); final again = await SettingsStore(p.join(dir.path, 'settings.json'), secrets).load(); expect(again.aiServices.single.apiKey, 'sk-secret'); s.aiServices = []; await st.save(s); expect(secrets.values.containsKey('ai.key.x'), isFalse, reason: '지운 서비스의 키는 저장소에서도 지움'); }); }); group('123: 기기 안 엔진 (가짜 sd-cli 로)', () { test('진행을 알리고, 여러 장을 시드와 함께 돌려주고, 옆에 json 을 남긴다', () async { if (!Platform.isWindows) return markTestSkipped('Windows 전용 (가짜 sd-cli 가 .cmd)'); final dir = Directory.systemTemp.createTempSync('jj_ai_local_'); addTearDown(() => dir.deleteSync(recursive: true)); final png = File(p.join(dir.path, 'src.png'))..writeAsBytesSync(_png); // 받은 인수의 -o (예: ...\ai_X_%d.png) 에 0 · 1 번을 만든다 final fake = File(p.join(dir.path, 'fake-sd.cmd')) ..writeAsStringSync('@echo off\r\n' 'echo ^|=====^> ^| 1/2 - 3.00s/it\r\n' 'echo ^|==========^| 2/2 - 3.00s/it\r\n' 'echo [I] decode_first_stage completed\r\n' ':loop\r\nif "%~1"=="" goto done\r\nif "%~1"=="-o" (set OUT=%~2)\r\nshift\r\ngoto loop\r\n:done\r\n' 'setlocal enabledelayedexpansion\r\nset A=!OUT:%%d=0!\r\nset B=!OUT:%%d=1!\r\n' 'copy /y "${png.path}" "%A%" >nul\r\ncopy /y "${png.path}" "%B%" >nul\r\n' 'echo [I] generate_image completed\r\n'); final engine = LocalSdEngine(device: SdDevice(fake.path, 'cpu', 'CPU'), models: const SdModelPaths(model: 'm')); final steps = []; final out = p.join(dir.path, 'out'); final made = await AiJobs.instance.run(engine, const ImageGenRequest(prompt: 'cat', seed: 100, count: 2), outDir: out); expect(made.map((g) => g.seed), [100, 101]); for (final g in made) { expect(File(g.path).existsSync(), isTrue); final j = jsonDecode(File('${p.withoutExtension(g.path)}.json').readAsStringSync()) as Map; expect(j['prompt'], 'cat'); expect(j['model'], 'sd15-lcm'); } expect(made.first.seed, (jsonDecode(File('${p.withoutExtension(made.first.path)}.json').readAsStringSync()) as Map)['seed']); expect(AiJobs.instance.busy, isFalse); expect(steps, isEmpty); }); test('엔진이 실패하면 마지막 출력으로 알린다', () async { if (!Platform.isWindows) return markTestSkipped('Windows 전용'); final dir = Directory.systemTemp.createTempSync('jj_ai_fail_'); addTearDown(() => dir.deleteSync(recursive: true)); final fake = File(p.join(dir.path, 'bad.cmd'))..writeAsStringSync('@echo off\r\necho [E] out of memory\r\nexit /b 3\r\n'); final engine = LocalSdEngine(device: SdDevice(fake.path, 'cpu', 'CPU'), models: const SdModelPaths(model: 'm')); await expectLater(engine.generate(const ImageGenRequest(prompt: 'x'), outDir: dir.path, baseName: 'a'), throwsA(isA().having((e) => e.message, 'message', contains('out of memory')))); }); }); test('123: AI 설정 처음 값 · 저장 · 예전 설정에 AI 그림 화면을 한 번 넣음', () { final s = AppSettings(); expect((s.aiEngine, s.aiDevice, s.aiTaesd, s.aiWidth, s.aiHeight, s.aiSteps, s.aiCfg, s.aiCount), ('local', 'auto', true, 512, 512, 4, 1.0, 1)); expect(s.components, contains('aiimage')); final b = AppSettings.fromJson((AppSettings() ..aiWidth = 768 ..aiSteps = 8 ..aiTaesd = false) .toJson()); expect((b.aiWidth, b.aiSteps, b.aiTaesd), (768, 8, false)); final old = AppSettings.fromJson({'components': ['mkv', 'browser']}); expect(old.components, contains('aiimage')); expect(old.migrated, contains('aiImage')); final off = AppSettings.fromJson((AppSettings()..components = ['mkv']).toJson()); expect(off.components, isNot(contains('aiimage')), reason: '사용자가 끈 것은 그대로'); }); }