import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; import { analyzeProductImages } from './analyze.mjs'; const projectRoot = path.resolve(import.meta.dirname, '..', '..', '..', '..', '..'); function tempWorkspace(prefix) { return fs.mkdtempSync(path.join(os.tmpdir(), `fmode-image-set-${prefix}-`)); } test('analysis rejects missing current-request image without calling provider', async () => { let called = false; const result = await analyzeProductImages({ allowClaudeAttachments: false }, { provider: { analyze: async () => { called = true; } } }); assert.equal(result.status, 'needs_input'); assert.equal(called, false); }); test('analysis validates structured Gemini result', async () => { const workspace = tempWorkspace('analysis'); const fixture = path.join(workspace, 'analysis.png'); fs.writeFileSync(fixture, Buffer.from([137, 80, 78, 71])); try { const provider = { analyze: async () => ({ model: 'fake-gemini', usage: {}, finishReason: 'stop', analysis: { visible_facts: [{ field: 'color', value: 'red', source_image: 1, confidence: 0.9 }], visual_inferences: [], unknowns: ['size'], product_identity: { product_name: '红瓶精华', category: 'beauty', shape: 'bottle', packaging: 'box', logo: '', visible_text: [], materials: [], colors: ['red'], distinctive_features: [] }, brand_style: { palette: ['#ff0000'], lighting: 'soft', mood: 'clean', composition: 'centered' }, quality_risks: [], recommended_reference_roles: [{ image: 1, role: 'identity' }] } }) }; const result = await analyzeProductImages({ imagePath: fixture, workspaceRoot: workspace, cwd: workspace, allowClaudeAttachments: false }, { provider, workspaceRoot: workspace }); assert.equal(result.status, 'ok'); assert.equal(result.data.analysis.visible_facts[0].value, 'red'); assert.equal(result.data.analysis.product_identity.product_name, '红瓶精华'); } finally { fs.rmSync(workspace, { recursive: true, force: true }); } }); test('analysis requires a complete role mapping for multiple images and passes it to Gemini', async () => { const workspace = tempWorkspace('roles'); const first = path.join(workspace, 'role-1.png'); const second = path.join(workspace, 'role-2.png'); fs.writeFileSync(first, Buffer.from([137, 80, 78, 71])); fs.writeFileSync(second, Buffer.from([137, 80, 78, 72])); let received; const provider = { analyze: async (_images, input) => { received = input.imageRoles; return { model: 'fake', usage: {}, finishReason: 'stop', analysis: {} }; } }; try { const incomplete = await analyzeProductImages({ images: [first, second], imageRoles: { 1: 'identity' }, workspaceRoot: workspace, cwd: workspace, allowClaudeAttachments: false }, { provider, workspaceRoot: workspace }); assert.equal(incomplete.status, 'needs_input'); const complete = await analyzeProductImages({ images: [first, second], imageRoles: { 1: 'identity', 2: 'detail' }, workspaceRoot: workspace, cwd: workspace, allowClaudeAttachments: false }, { provider, workspaceRoot: workspace }); assert.equal(complete.status, 'ok'); assert.deepEqual(received.map(item => item.role), ['identity', 'detail']); } finally { fs.rmSync(workspace, { recursive: true, force: true }); } }); test('analysis rejects a local image outside the declared workspace', async () => { const parent = tempWorkspace('outside-boundary'); const workspace = path.join(parent, 'workspace'); const outside = path.join(parent, 'outside.png'); fs.mkdirSync(workspace, { recursive: true }); fs.writeFileSync(outside, Buffer.from([137, 80, 78, 71])); try { const result = await analyzeProductImages({ imagePath: outside, workspaceRoot: workspace, cwd: workspace, allowClaudeAttachments: false }, { workspaceRoot: workspace, provider: { analyze: async () => ({ analysis: {} }) } }); assert.equal(result.status, 'needs_input'); assert.match(result.assistantMessage, /当前项目内/); } finally { fs.rmSync(parent, { recursive: true, force: true }); } });