import assert from 'node:assert/strict'; import fs from 'node:fs'; import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; import { type AppConfig } from '../config.js'; import type { AppContext } from '../context.js'; import { generateSceneImage } from './sceneImageService.js'; const PNG_BUFFER = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO7+7aQAAAAASUVORK5CYII=', 'base64', ); function createTestConfig( projectRoot: string, dashScopeBaseUrl: string, ): AppConfig { return { projectRoot, publicDir: path.join(projectRoot, 'public'), dashScope: { baseUrl: dashScopeBaseUrl, apiKey: 'test-dashscope-key', imageModel: 'wan2.2-t2i-flash', requestTimeoutMs: 5_000, }, } as AppConfig; } function readRequestBody(req: IncomingMessage) { return new Promise((resolve, reject) => { const chunks: Buffer[] = []; req.on('data', (chunk) => { chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); }); req.on('end', () => resolve(Buffer.concat(chunks))); req.on('error', reject); }); } function sendJson(res: ServerResponse, payload: unknown) { res.statusCode = 200; res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.end(JSON.stringify(payload)); } async function withHttpServer( buildHandler: (baseUrl: string) => ( req: IncomingMessage, res: ServerResponse, ) => void | Promise, run: (baseUrl: string) => Promise, ) { let handler: ( req: IncomingMessage, res: ServerResponse, ) => void | Promise = () => undefined; const server = createServer((req, res) => { Promise.resolve(handler(req, res)).catch((error) => { res.statusCode = 500; res.end(error instanceof Error ? error.stack : String(error)); }); }); await new Promise((resolve) => { server.listen(0, '127.0.0.1', () => resolve()); }); const address = server.address(); if (!address || typeof address === 'string') { throw new Error('failed to resolve test server address'); } const baseUrl = `http://127.0.0.1:${address.port}`; handler = buildHandler(baseUrl); try { return await run(baseUrl); } finally { await new Promise((resolve, reject) => { server.close((error) => { if (error) { reject(error); return; } resolve(); }); }); } } test('generateSceneImage uses wan2.2-t2i-flash text-to-image payload and saves the generated scene', async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genarrative-scene-image-')); const capturedRequests: Array<{ pathname: string; bodyText?: string; }> = []; await withHttpServer( (baseUrl) => async (req, res) => { const url = new URL(req.url || '/', baseUrl); const bodyText = req.method === 'POST' ? (await readRequestBody(req)).toString('utf8') : undefined; capturedRequests.push({ pathname: url.pathname, bodyText, }); if ( req.method === 'POST' && url.pathname === '/api/v1/services/aigc/image-generation/generation' ) { sendJson(res, { output: { task_id: 'scene-task-1', }, }); return; } if (req.method === 'GET' && url.pathname === '/api/v1/tasks/scene-task-1') { sendJson(res, { output: { task_status: 'SUCCEEDED', results: [ { url: `${baseUrl}/downloads/scene.png`, actual_prompt: '整理后的场景提示词', }, ], }, }); return; } if (req.method === 'GET' && url.pathname === '/downloads/scene.png') { res.statusCode = 200; res.setHeader('Content-Type', 'image/png'); res.end(PNG_BUFFER); return; } res.statusCode = 404; res.end('not found'); }, async (dashScopeBaseUrl) => { const context = { config: createTestConfig(tempRoot, `${dashScopeBaseUrl}/api/v1`), } as AppContext; const result = await generateSceneImage(context, { prompt: '海雾港口像素风场景', negativePrompt: '模糊', size: '1280*720', model: 'wan2.7-image', worldName: '潮雾群岛', profileId: 'world-1', landmarkName: '旧港灯塔', landmarkId: 'landmark-1', }); assert.equal(result.ok, true); assert.match(result.imageSrc, /^\/generated-custom-world-scenes\//u); assert.equal(result.actualPrompt, '整理后的场景提示词'); const createRequest = capturedRequests.find( (entry) => entry.pathname === '/api/v1/services/aigc/image-generation/generation', ); assert.ok(createRequest?.bodyText); const createPayload = JSON.parse(createRequest.bodyText) as { model: string; input: { messages: Array<{ content: Array<{ text?: string; image?: string }>; }>; }; parameters: { negative_prompt?: string; }; }; const content = createPayload.input.messages[0]?.content ?? []; assert.equal(createPayload.model, 'wan2.2-t2i-flash'); assert.equal(content[0]?.text, '海雾港口像素风场景'); assert.equal(content.length, 1); assert.equal(createPayload.parameters.negative_prompt, '模糊'); const savedImagePath = path.join(tempRoot, 'public', result.imageSrc.slice(1)); assert.equal(fs.existsSync(savedImagePath), true); }, ); }); test('generateSceneImage uses qwen-image-2.0 edit flow when a reference image is provided', async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genarrative-scene-image-')); const publicDir = path.join(tempRoot, 'public'); fs.mkdirSync(path.join(publicDir, 'scene_bg'), { recursive: true }); fs.writeFileSync(path.join(publicDir, 'scene_bg', 'reference-layout.png'), PNG_BUFFER); const capturedRequests: Array<{ pathname: string; bodyText?: string; }> = []; await withHttpServer( (baseUrl) => async (req, res) => { const url = new URL(req.url || '/', baseUrl); const bodyText = req.method === 'POST' ? (await readRequestBody(req)).toString('utf8') : undefined; capturedRequests.push({ pathname: url.pathname, bodyText, }); if ( req.method === 'POST' && url.pathname === '/api/v1/services/aigc/multimodal-generation/generation' ) { sendJson(res, { output: { choices: [ { message: { content: [ { image: `${baseUrl}/downloads/reference-scene.png`, }, ], }, }, ], }, }); return; } if (req.method === 'GET' && url.pathname === '/downloads/reference-scene.png') { res.statusCode = 200; res.setHeader('Content-Type', 'image/png'); res.end(PNG_BUFFER); return; } res.statusCode = 404; res.end('not found'); }, async (dashScopeBaseUrl) => { const context = { config: createTestConfig(tempRoot, `${dashScopeBaseUrl}/api/v1`), } as AppContext; const result = await generateSceneImage(context, { prompt: '废墟月台像素风场景', negativePrompt: '模糊', size: '1280*720', worldName: '碎轨边境', profileId: 'world-2', landmarkName: '裂轨月台', landmarkId: 'landmark-2', referenceImageSrc: '/scene_bg/reference-layout.png', }); assert.equal(result.ok, true); assert.equal(result.model, 'qwen-image-2.0'); assert.match(result.taskId, /^scene-edit-/u); assert.equal( capturedRequests.some( (entry) => entry.pathname === '/api/v1/tasks/scene-task-1', ), false, ); const createRequest = capturedRequests.find( (entry) => entry.pathname === '/api/v1/services/aigc/multimodal-generation/generation', ); assert.ok(createRequest?.bodyText); const createPayload = JSON.parse(createRequest.bodyText) as { model: string; input: { messages: Array<{ content: Array<{ text?: string; image?: string }>; }>; }; }; const content = createPayload.input.messages[0]?.content ?? []; assert.equal(createPayload.model, 'qwen-image-2.0'); assert.match(content[0]?.image ?? '', /^data:image\/png;base64,/u); assert.equal(content[1]?.text, '废墟月台像素风场景'); }, ); });