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/text2image/image-synthesis' ) { 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/text2image/image-synthesis', ); assert.ok(createRequest?.bodyText); const createPayload = JSON.parse(createRequest.bodyText) as { model: string; input: { prompt: string; negative_prompt?: string; }; parameters: Record; }; assert.equal(createPayload.model, 'wan2.2-t2i-flash'); assert.equal(createPayload.input.prompt, '海雾港口像素风场景'); assert.equal(createPayload.input.negative_prompt, '模糊'); assert.equal(createPayload.parameters.size, '1280*720'); const savedImagePath = path.join( tempRoot, 'public', result.imageSrc.slice(1), ); assert.equal(fs.existsSync(savedImagePath), true); }, ); }); test('generateSceneImage builds the scene prompt on the server when the client only submits world and landmark context', 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/text2image/image-synthesis' ) { sendJson(res, { output: { task_id: 'scene-task-2', }, }); return; } if ( req.method === 'GET' && url.pathname === '/api/v1/tasks/scene-task-2' ) { 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, { worldName: '', profileId: '', landmarkName: '', landmarkId: '', userPrompt: '想让灯塔更偏暴风夜', profile: { id: 'world-3', name: '潮雾群岛', subtitle: '迷雾海界', summary: '岛链被旧航道和风暴一起缠住。', tone: '潮湿、压迫、带着未知回声', playerGoal: '先找到断线的引路火', settingText: '玩家在海雾和旧航道之间寻找可以靠岸的线索。', }, landmark: { id: 'landmark-3', name: '旧港灯塔', description: '灯塔外墙被海盐侵蚀,塔下平台还能勉强落脚。', dangerLevel: 'high', }, }); assert.equal(result.ok, true); const createRequest = capturedRequests.find( (entry) => entry.pathname === '/api/v1/services/aigc/text2image/image-synthesis', ); assert.ok(createRequest?.bodyText); const createPayload = JSON.parse(createRequest.bodyText) as { input: { prompt: string; negative_prompt?: string; }; }; assert.match(createPayload.input.prompt, /世界:潮雾群岛,迷雾海界。/u); assert.match(createPayload.input.prompt, /场景名称:旧港灯塔。/u); assert.match( createPayload.input.prompt, /本次想要生成的画面内容:想让灯塔更偏暴风夜。/u, ); assert.match(createPayload.input.prompt, /危险感强烈/u); assert.equal( createPayload.input.negative_prompt, '文字,水印,logo,UI界面,对话框,边框,人物近景特写,多人合照,模糊,低清晰度,畸形建筑,现代车辆,监控摄像头', ); }, ); }); 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, '废墟月台像素风场景'); }, ); });