import { EditingForm, type EditingFormData } from './editing-form'; import { I18nProvider } from '@/lib/i18n'; import { IMAGE_UPSTREAM_PROFILES, type ImageUpstreamProfile, type PartialImagesCount } from '@/lib/image-upstream-profile'; import type { ImageStreamingStrategy } from '@/lib/image-upstream-strategy'; import { renderInClientDom } from '@/test-utils/react-dom'; import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import * as React from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; type RenderOptions = { backend: EditingFormData['image_backend']; outputFormat?: EditingFormData['output_format']; advancedOpen?: boolean; advancedTab?: 'output' | 'model' | 'stream' | 'route'; reuseContext?: React.ComponentProps['reuseContext']; allowStreamingBatch?: boolean; enableParallelBatch?: boolean; editN?: number[]; streamingStrategy?: EditingFormData['streaming_strategy']; defaultStreamingStrategy?: ImageStreamingStrategy; allowResponsesImageBackend?: boolean; hasDefaultResponsesModel?: boolean; editResponsesModel?: string; editPrompt?: string; editModel?: React.ComponentProps['editModel']; editSize?: React.ComponentProps['editSize']; canApplyRandomInspiration?: boolean; imageFiles?: File[]; upstreamProfile?: ImageUpstreamProfile; upstreamProfileMixed?: boolean; isActive?: boolean; partialImages?: PartialImagesCount; streamMode?: React.ComponentProps['streamMode']; isLoading?: boolean; showLoadingState?: boolean; }; const noop = () => {}; function readSubmitFooterClass(html: string, submitLabel: string): string { const footerPattern = /]*data-slot="card-footer")(?=[^>]*class="([^"]*)")[^>]*>/g; const footers = [...html.matchAll(footerPattern)]; for (const [index, footer] of footers.entries()) { const start = footer.index; const end = footers[index + 1]?.index ?? html.length; const footerHtml = html.slice(start, end); if (footerHtml.includes(submitLabel)) return footer[1]; } assert.fail(`missing submit footer for ${submitLabel}`); } function assertSubmitFooterAvailable(html: string, submitLabel: string) { const classNames = readSubmitFooterClass(html, submitLabel).split(/\s+/); const hiddenClassName = classNames.find((className) => className === 'hidden' || className.endsWith(':hidden')); assert.equal(hiddenClassName, undefined); assert.equal(classNames.includes('flex'), true); assert.equal(classNames.includes('border-t'), true); } function assertButtonDisabled(html: string, label: string) { const button = html.match(/]*>[\s\S]*?<\/button>/g)?.find((candidate) => candidate.includes(label)); assert.ok(button, `missing button for ${label}`); assert.match(button, /^]*disabled=""/); } function createEditingFormProps({ backend, outputFormat = 'png', advancedOpen = true, advancedTab = 'route', reuseContext = null, allowStreamingBatch = false, enableParallelBatch = false, editN = [1], streamingStrategy = 'server-default', defaultStreamingStrategy = 'auto', allowResponsesImageBackend = true, hasDefaultResponsesModel = true, editResponsesModel = '', editPrompt = '', editModel = 'gpt-image-2', editSize = 'auto', canApplyRandomInspiration = true, imageFiles = [], upstreamProfile = IMAGE_UPSTREAM_PROFILES['openai-compatible'], upstreamProfileMixed = false, isActive = true, partialImages = 1, streamMode = 'auto', isLoading = false, showLoadingState = isLoading }: RenderOptions): React.ComponentProps { return { onSubmit: noop, onSaveInspiration: noop, canApplyRandomInspiration, onPickRandomInspiration: () => '用户保存的编辑提示词', isLoading, showLoadingState, isActive, currentMode: 'edit', onModeChange: noop, reuseContext, onClearReuseContext: noop, isPasswordRequiredByBackend: false, clientPasswordHash: null, onOpenPasswordDialog: noop, editModel, setEditModel: noop, imageFiles, sourceImagePreviewUrls: [], setImageFiles: noop, setSourceImagePreviewUrls: noop, maxImages: upstreamProfile.upload.maxImages, editPrompt, setEditPrompt: noop, editN, setEditN: noop, editSize, setEditSize: noop, editCustomWidth: 1024, setEditCustomWidth: noop, editCustomHeight: 1024, setEditCustomHeight: noop, editQuality: 'auto', setEditQuality: noop, editOutputFormat: outputFormat, setEditOutputFormat: noop, editCompression: [85], setEditCompression: noop, upstreamProfile, upstreamProfileMixed, editModeration: 'auto', setEditModeration: noop, editBrushSize: [20], setEditBrushSize: noop, editShowMaskEditor: false, setEditShowMaskEditor: noop, editGeneratedMaskFile: null, setEditGeneratedMaskFile: noop, editIsMaskSaved: false, setEditIsMaskSaved: noop, editOriginalImageSize: null, setEditOriginalImageSize: noop, editDrawnPoints: [], setEditDrawnPoints: noop, editMaskPreviewUrl: null, setEditMaskPreviewUrl: noop, streamMode, setStreamMode: noop, allowStreamingBatch, enableParallelBatch, setEnableParallelBatch: noop, partialImages, setPartialImages: noop, allowResponsesImageBackend, hasDefaultResponsesModel, editImageBackend: backend, setEditImageBackend: noop, editStreamingStrategy: streamingStrategy, defaultStreamingStrategy, setEditStreamingStrategy: noop, editResponsesModel, setEditResponsesModel: noop, editThinking: 'server-default', setEditThinking: noop, editPromptOptimization: 'server-default', setEditPromptOptimization: noop, editForceWeb: false, setEditForceWeb: noop, requestSummaryLabel: '请求 1 张图片', initialAdvancedOpen: advancedOpen, initialAdvancedTab: advancedTab }; } function renderEditingForm(options: RenderOptions): string { return renderToStaticMarkup( ); } describe('EditingForm submit footer', { concurrency: false }, () => { it('keeps the submit footer available outside desktop breakpoints', () => { const html = renderEditingForm({ backend: 'server-default', editPrompt: '用户真实编辑要求', imageFiles: [new File(['x'], 'source.png', { type: 'image/png' })] }); assertSubmitFooterAvailable(html, '编辑图像'); assert.match(html, /grid grid-cols-\[minmax\(0,1fr\)_minmax\(0,1fr\)\] gap-1\.5 text-xs/); assert.match(html, /min-w-0 items-center justify-center rounded-md/); assert.doesNotMatch(html, /text-center whitespace-nowrap/); assert.doesNotMatch(html, /min-\[1760px\]:flex/); assert.match(html, /收藏提示词/); assert.match(html, /随机套用/); assert.match(html, /min-h-11 min-w-0 items-center justify-center/); assert.match(html, /focus-visible:ring-2 focus-visible:outline-none/); assert.match(html, /flex min-h-0 flex-1 flex-col overflow-hidden/); assert.match(html, /min-h-0 flex-1 space-y-3 overflow-y-auto p-4 pb-4/); assert.match(html, /border-border bg-card flex shrink-0 border-t p-4/); }); it('keeps submit copy while another busy action disables the form', () => { const html = renderEditingForm({ backend: 'server-default', isLoading: true, showLoadingState: false }); assert.match(html, /编辑图像/); assert.doesNotMatch(html, /编辑中\.\.\./); }); it('disables edit inspiration actions when the prompt or saved inspirations are unavailable', () => { const html = renderEditingForm({ backend: 'server-default', editPrompt: '', canApplyRandomInspiration: false }); assertButtonDisabled(html, '收藏提示词'); assertButtonDisabled(html, '随机套用'); assert.doesNotMatch(html, /用户保存的编辑提示词/); }); it('runs edit inspiration actions through the rendered footer controls', async () => { const savedPrompts: string[] = []; const appliedPrompts: string[] = []; const props = createEditingFormProps({ backend: 'server-default', editPrompt: ' 当前编辑灵感 ' }); props.onSaveInspiration = (prompt) => savedPrompts.push(prompt); props.onPickRandomInspiration = () => ' 随机编辑灵感 '; props.setEditPrompt = (nextPrompt) => { appliedPrompts.push(typeof nextPrompt === 'function' ? nextPrompt('') : nextPrompt); }; const view = await renderInClientDom( ); try { const saveButton = [...view.container.querySelectorAll('button')].find((button) => button.textContent?.includes('收藏提示词') ); const randomButton = [...view.container.querySelectorAll('button')].find((button) => button.textContent?.includes('随机套用') ); assert.ok(saveButton, 'missing edit save inspiration button'); assert.ok(randomButton, 'missing edit random inspiration button'); await view.click(saveButton); await view.click(randomButton); assert.deepEqual(savedPrompts, [' 当前编辑灵感 ']); assert.deepEqual(appliedPrompts, ['随机编辑灵感']); } finally { await view.cleanup(); } }); it('only applies edit profile corrections after the form becomes active', async () => { const countCorrections: number[][] = []; const partialImageCorrections: number[] = []; const streamModeCorrections: string[] = []; const sizeCorrections: string[] = []; const props = createEditingFormProps({ backend: 'server-default', isActive: false, editN: [0], editModel: 'gpt-image-1', editSize: 'custom', partialImages: 0, streamMode: 'auto', streamingStrategy: 'off' }); props.setEditN = (nextCount) => { countCorrections.push(typeof nextCount === 'function' ? nextCount([]) : nextCount); }; props.setPartialImages = (nextPartialImages) => { partialImageCorrections.push( typeof nextPartialImages === 'function' ? nextPartialImages(1) : nextPartialImages ); }; props.setStreamMode = (nextStreamMode) => { streamModeCorrections.push(typeof nextStreamMode === 'function' ? nextStreamMode('auto') : nextStreamMode); }; props.setEditSize = (nextSize) => { sizeCorrections.push(typeof nextSize === 'function' ? nextSize('custom') : nextSize); }; const view = await renderInClientDom( ); try { assert.deepEqual(countCorrections, []); assert.deepEqual(partialImageCorrections, []); assert.deepEqual(streamModeCorrections, []); assert.deepEqual(sizeCorrections, []); await view.render( ); assert.deepEqual(countCorrections, [[1]]); assert.deepEqual(partialImageCorrections, [1]); assert.deepEqual(streamModeCorrections, ['non_stream']); assert.deepEqual(sizeCorrections, ['auto']); } finally { await view.cleanup(); } }); }); describe('EditingForm advanced upstream controls', () => { it('keeps translated preset labels inside responsive control cells', () => { const html = renderEditingForm({ backend: 'server-default' }); assert.match(html, /radio-group-item-content\]\]:min-w-0/); assert.match(html, /radio-group-item-content\]\]:overflow-hidden/); assert.match(html, /max-w-full min-w-0 text-center leading-4 break-words whitespace-normal/); assert.doesNotMatch(html, /radio-group-item-content\]\]:overflow-visible/); assert.doesNotMatch(html, /2xl:grid-cols-5/); assert.doesNotMatch(html, /lg:hidden 2xl:block/); }); it('keeps the full professional accordion available on desktop and mobile', () => { const html = renderEditingForm({ backend: 'server-default', advancedTab: 'route' }); assert.match( html, /
]*aria-controls="editing-advanced-panel"/ ); assert.doesNotMatch( html, /
]*aria-controls="editing-advanced-panel"/ ); }); it('keeps model and streaming controls out of the default edit form surface', () => { const html = renderEditingForm({ backend: 'server-default', advancedOpen: false }); assert.match(html, /参考图/); assert.match(html, /修改想法/); assert.match(html, /专业模式/); assert.doesNotMatch(html, /block truncate text-xs font-normal/); assert.doesNotMatch(html, /edit-model-select/); assert.doesNotMatch(html, /edit-stream-mode-select/); }); it('translates the default backend into a user-facing route label near submit', () => { const html = renderEditingForm({ backend: 'server-default', advancedOpen: false }); assert.match(html, /默认线路/); assert.match(html, /请求 1 张图片/); }); it('renders edit model controls only in the professional model tab', () => { const html = renderEditingForm({ backend: 'server-default', advancedTab: 'model' }); assert.match(html, /edit-model-select/); assert.match(html, /gpt-image-2 始终以高保真方式处理参考图/); assert.doesNotMatch(html, /edit-image-backend-select/); }); it('renders edit stream controls only in the professional stream tab', () => { const html = renderEditingForm({ backend: 'server-default', advancedTab: 'stream' }); assert.match(html, /edit-stream-mode-select/); assert.match(html, /edit-partial-1/); assert.doesNotMatch(html, /edit-model-select/); }); it('hides Matsca-only edit stream controls for the default OpenAI-compatible profile', () => { const html = renderEditingForm({ backend: 'server-default', advancedTab: 'stream' }); assert.doesNotMatch(html, /edit-partial-0/); assert.match(html, /edit-partial-1/); assert.match(html, /edit-partial-3/); assert.doesNotMatch(html, /edit-partial-4/); }); it('renders Matsca edit stream controls when the active upstream profile allows them', () => { const html = renderEditingForm({ backend: 'server-default', advancedTab: 'stream', upstreamProfile: IMAGE_UPSTREAM_PROFILES.matsca }); assert.match(html, /edit-partial-0/); assert.match(html, /edit-partial-4/); }); it('intersects Matsca edit partial image options with the Responses backend contract', () => { const html = renderEditingForm({ backend: 'responses-image-generation', advancedTab: 'stream', upstreamProfile: IMAGE_UPSTREAM_PROFILES.matsca }); assert.doesNotMatch(html, /edit-partial-0/); assert.match(html, /edit-partial-1/); assert.match(html, /edit-partial-3/); assert.doesNotMatch(html, /edit-partial-4/); }); it('uses Matsca edit upload limits when the active upstream profile requires them', () => { const imageFiles = Array.from( { length: 9 }, (_, index) => new File(['x'], `source-${index}.png`, { type: 'image/png' }) ); const html = renderEditingForm({ backend: 'server-default', advancedOpen: false, editPrompt: '用户真实编辑要求', imageFiles, upstreamProfile: IMAGE_UPSTREAM_PROFILES.matsca }); assert.match(html, /最多 8 张/); assert.match(html, /9 \/ 8 张/); assert.match(html, /最多只能选择 8 张图片。/); assert.match(html, /]*disabled=""[^>]*>[\s\S]*编辑图像[\s\S]*<\/button>/); }); it('renders profile-aware high resolution edit size presets', () => { const openAiHtml = renderEditingForm({ backend: 'server-default' }); const matscaHtml = renderEditingForm({ backend: 'server-default', upstreamProfile: IMAGE_UPSTREAM_PROFILES.matsca }); assert.match(openAiHtml, /id="edit-size-wide-4k"/); assert.doesNotMatch(openAiHtml, /id="edit-size-square-4k"/); assert.match(matscaHtml, /id="edit-size-square-4k"/); }); it('renders an explicit parallel batch toggle in edit stream settings', () => { const html = renderEditingForm({ backend: 'server-default', advancedTab: 'stream', allowStreamingBatch: true, enableParallelBatch: true, editN: [2] }); assert.match(html, /并发批量/); assert.match(html, /多张图或多条提示词会按当前渠道容量并发执行/); assert.match(html, /id="edit-parallel-batch-enabled"/); assert.match(html, /aria-checked="true"/); }); it('keeps edit parallel batch disabled for a single output image', () => { const html = renderEditingForm({ backend: 'server-default', advancedTab: 'stream', allowStreamingBatch: true, enableParallelBatch: true }); assert.match(html, /选择至少 2 张图片或 2 条提示词后可启用并发/); assert.match(html, /id="edit-parallel-batch-enabled"/); assert.match(html, /aria-checked="false"/); assert.match(html, /disabled=""/); }); it('keeps edit parallel batch disabled when streaming strategy is off', () => { const html = renderEditingForm({ backend: 'server-default', advancedTab: 'stream', allowStreamingBatch: true, enableParallelBatch: true, editN: [2], streamingStrategy: 'off' }); assert.match(html, /并发批量需要流式模式;非流式会保持顺序执行。/); assert.match(html, /id="edit-parallel-batch-enabled"/); assert.match(html, /aria-checked="false"/); assert.match(html, /disabled=""/); }); it('keeps edit parallel batch disabled when the server default streaming strategy is off', () => { const html = renderEditingForm({ backend: 'server-default', advancedTab: 'stream', allowStreamingBatch: true, enableParallelBatch: true, editN: [2], defaultStreamingStrategy: 'off' }); assert.match(html, /并发批量需要流式模式;非流式会保持顺序执行。/); assert.match(html, /id="edit-parallel-batch-enabled"/); assert.match(html, /aria-checked="false"/); assert.match(html, /disabled=""/); }); it('disables the edit stream mode selector when the server default streaming strategy is off', () => { const html = renderEditingForm({ backend: 'server-default', advancedTab: 'stream', defaultStreamingStrategy: 'off' }); assert.match( html, /]*(?:disabled=""[^>]*id="edit-stream-mode-select"|id="edit-stream-mode-select"[^>]*disabled="")/ ); }); it('renders Responses-specific edit controls when the Responses backend is selected', () => { const html = renderEditingForm({ backend: 'responses-image-generation', upstreamProfileMixed: true }); assert.match(html, /图片生成后端/); assert.match(html, /影响说明/); assert.match(html, /Responses image_generation 需要实验开关和顶层模型/); assert.match(html, /当前服务端渠道包含不同上游模式/); assert.match(html, /自动或服务端默认会优先使用当前推荐的流式策略/); assert.match(html, /GPT 顶层模型/); assert.match(html, /思考强度/); assert.match(html, /提示词优化/); assert.doesNotMatch(html, /优先 Web 账号/); }); it('explains the resolved edit server default streaming strategy', () => { const offHtml = renderEditingForm({ backend: 'server-default', advancedTab: 'route', streamingStrategy: 'server-default', defaultStreamingStrategy: 'off' }); const forceHtml = renderEditingForm({ backend: 'server-default', advancedTab: 'route', streamingStrategy: 'server-default', defaultStreamingStrategy: 'force-sse' }); assert.match(offHtml, /关闭流式会减少长连接不稳定因素/); assert.doesNotMatch(offHtml, /自动或服务端默认会优先使用当前推荐的流式策略/); assert.match(forceHtml, /强制 SSE 会跳过自动判断/); assert.doesNotMatch(forceHtml, /自动或服务端默认会优先使用当前推荐的流式策略/); }); it('disables the experimental Responses backend when runtime capabilities do not allow it', () => { const html = renderEditingForm({ backend: 'server-default', allowResponsesImageBackend: false }); assert.match(html, /当前运行时或默认服务器渠道未开放 Responses image_generation/); assert.doesNotMatch(html, /GPT 顶层模型/); }); it('blocks Responses edits until a top-level model is available', () => { const html = renderEditingForm({ backend: 'responses-image-generation', hasDefaultResponsesModel: false, editResponsesModel: '', editPrompt: '用户真实编辑要求', imageFiles: [new File(['x'], 'source.png', { type: 'image/png' })] }); assert.match(html, /Responses image_generation 需要填写 GPT 顶层模型/); assert.match(html, /]*disabled=""[^>]*>[\s\S]*编辑图像[\s\S]*<\/button>/); }); it('renders Images API edit controls and compression when JPEG output is selected', () => { const html = renderEditingForm({ backend: 'images-api', outputFormat: 'jpeg', advancedTab: 'output' }); assert.match(html, /Images API/); assert.match(html, /输出格式/); assert.match(html, /压缩:85%/); assert.match(html, /内容审核级别/); assert.doesNotMatch(html, /GPT 顶层模型/); }); }); describe('EditingForm reused history context', () => { it('shows which history values were carried into edit mode', () => { const html = renderEditingForm({ backend: 'server-default', reuseContext: { sourceLabel: '最近生成:2026/6/2 12:00:00', restoredFields: ['参考图', '提示词', '模型', '尺寸', '数量'], promptPreview: '用户真实编辑提示词' } }); assert.match(html, /已带入内容/); assert.match(html, /最近生成:2026\/6\/2 12:00:00/); assert.match(html, /参考图/); assert.match(html, /提示词/); assert.match(html, /模型/); assert.match(html, /尺寸/); assert.match(html, /数量/); assert.match(html, /用户真实编辑提示词/); assert.match(html, /这些内容已经写入编辑单,可以修改后再生成。/); }); });