Spaces:
Running
Running
| import { GenerationForm, resolveGenerationFooterPromptTarget } from './generation-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'; | |
| const noop = () => {}; | |
| function readButtonById(html: string, id: string): string { | |
| const match = html.match(new RegExp(`<button[^>]*id="${id}"[^>]*>`)); | |
| assert.ok(match, `missing button ${id}`); | |
| return match[0]; | |
| } | |
| function readSubmitFooterClass(html: string, submitLabel: string): string { | |
| const footerPattern = /<div(?=[^>]*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); | |
| } | |
| type GenerationRenderOptions = { | |
| currentMode?: 'generate' | 'edit' | 'batch' | 'reuse'; | |
| defaultAdvancedTab?: 'output' | 'model' | 'stream' | 'route'; | |
| failedBatchPrompts?: string[]; | |
| canPauseBatch?: boolean; | |
| isBatchPauseRequested?: boolean; | |
| defaultAdvancedOpen?: boolean; | |
| omitPauseHandler?: boolean; | |
| allowStreamingBatch?: boolean; | |
| enableParallelBatch?: boolean; | |
| streamingStrategy?: React.ComponentProps<typeof GenerationForm>['streamingStrategy']; | |
| defaultStreamingStrategy?: ImageStreamingStrategy; | |
| prompt?: string; | |
| batchPromptText?: string; | |
| canApplyRandomInspiration?: boolean; | |
| allowResponsesImageBackend?: boolean; | |
| hasDefaultResponsesModel?: boolean; | |
| responsesModel?: string; | |
| imageBackend?: React.ComponentProps<typeof GenerationForm>['imageBackend']; | |
| upstreamProfile?: ImageUpstreamProfile; | |
| upstreamProfileMixed?: boolean; | |
| isActive?: boolean; | |
| n?: number[]; | |
| partialImages?: PartialImagesCount; | |
| streamMode?: React.ComponentProps<typeof GenerationForm>['streamMode']; | |
| model?: React.ComponentProps<typeof GenerationForm>['model']; | |
| size?: React.ComponentProps<typeof GenerationForm>['size']; | |
| isLoading?: boolean; | |
| showLoadingState?: boolean; | |
| }; | |
| function createGenerationFormProps(options: GenerationRenderOptions = {}): React.ComponentProps<typeof GenerationForm> { | |
| return { | |
| onSubmit: noop, | |
| onSaveInspiration: noop, | |
| canApplyRandomInspiration: options.canApplyRandomInspiration ?? true, | |
| onPickRandomInspiration: () => '用户保存的真实提示词', | |
| isLoading: options.isLoading ?? false, | |
| showLoadingState: options.showLoadingState, | |
| isActive: options.isActive ?? true, | |
| currentMode: options.currentMode ?? 'generate', | |
| onModeChange: noop, | |
| reuseContext: null, | |
| onClearReuseContext: noop, | |
| isPasswordRequiredByBackend: false, | |
| clientPasswordHash: null, | |
| onOpenPasswordDialog: noop, | |
| model: options.model ?? 'gpt-image-2', | |
| setModel: noop, | |
| prompt: options.prompt ?? '用户真实提示词 A', | |
| setPrompt: noop, | |
| batchPromptText: options.batchPromptText ?? '用户真实提示词 A\n用户真实提示词 B', | |
| setBatchPromptText: noop, | |
| failedBatchPrompts: options.failedBatchPrompts, | |
| canPauseBatch: options.canPauseBatch, | |
| isBatchPauseRequested: options.isBatchPauseRequested, | |
| onPauseBatch: options.omitPauseHandler ? undefined : noop, | |
| n: options.n ?? [1], | |
| setN: noop, | |
| size: options.size ?? 'auto', | |
| setSize: noop, | |
| customWidth: 1024, | |
| setCustomWidth: noop, | |
| customHeight: 1024, | |
| setCustomHeight: noop, | |
| quality: 'high', | |
| setQuality: noop, | |
| outputFormat: 'png', | |
| setOutputFormat: noop, | |
| compression: [100], | |
| setCompression: noop, | |
| background: 'auto', | |
| setBackground: noop, | |
| upstreamProfile: options.upstreamProfile ?? IMAGE_UPSTREAM_PROFILES['openai-compatible'], | |
| upstreamProfileMixed: options.upstreamProfileMixed ?? false, | |
| moderation: 'auto', | |
| setModeration: noop, | |
| streamMode: options.streamMode ?? 'auto', | |
| setStreamMode: noop, | |
| allowStreamingBatch: options.allowStreamingBatch ?? false, | |
| enableParallelBatch: options.enableParallelBatch ?? false, | |
| setEnableParallelBatch: noop, | |
| partialImages: options.partialImages ?? 1, | |
| setPartialImages: noop, | |
| allowResponsesImageBackend: options.allowResponsesImageBackend ?? true, | |
| hasDefaultResponsesModel: options.hasDefaultResponsesModel ?? true, | |
| imageBackend: options.imageBackend ?? 'server-default', | |
| setImageBackend: noop, | |
| streamingStrategy: options.streamingStrategy ?? 'server-default', | |
| defaultStreamingStrategy: options.defaultStreamingStrategy ?? 'auto', | |
| setStreamingStrategy: noop, | |
| responsesModel: options.responsesModel ?? '', | |
| setResponsesModel: noop, | |
| thinking: 'server-default', | |
| setThinking: noop, | |
| promptOptimization: 'server-default', | |
| setPromptOptimization: noop, | |
| forceWeb: false, | |
| setForceWeb: noop, | |
| requestSummaryLabel: '请求 1 张图片', | |
| defaultAdvancedOpen: options.defaultAdvancedOpen, | |
| defaultAdvancedTab: options.defaultAdvancedTab | |
| }; | |
| } | |
| function renderGenerationForm(options: GenerationRenderOptions = {}) { | |
| return renderToStaticMarkup( | |
| <I18nProvider> | |
| <GenerationForm {...createGenerationFormProps(options)} /> | |
| </I18nProvider> | |
| ); | |
| } | |
| describe('GenerationForm submit footer', () => { | |
| it('keeps the form disabled without showing generation copy for other busy work', () => { | |
| const html = renderGenerationForm({ isLoading: true, showLoadingState: false }); | |
| assert.match(html, /disabled=""/); | |
| assert.match(html, />生成图像<\/button>/); | |
| assert.doesNotMatch(html, /生成中/); | |
| assert.doesNotMatch(html, /animate-spin/); | |
| }); | |
| it('shows generation loading feedback for a real generation request', () => { | |
| const html = renderGenerationForm({ isLoading: true, showLoadingState: true }); | |
| assert.match(html, /disabled=""/); | |
| assert.match(html, /animate-spin/); | |
| assert.match(html, /生成中\.\.\.<\/button>/); | |
| assert.doesNotMatch(html, /生成图像<\/button>/); | |
| }); | |
| it('keeps the submit footer available outside desktop breakpoints', () => { | |
| const html = renderGenerationForm(); | |
| 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.match(html, /min-h-11 min-w-0 items-center justify-center/); | |
| assert.match(html, /h-3\.5 w-3\.5 shrink-0" aria-hidden="true"/); | |
| assert.match(html, /min-w-0 whitespace-normal/); | |
| assert.match(html, /focus-visible:ring-2 focus-visible:outline-none/); | |
| assert.doesNotMatch(html, /text-center whitespace-nowrap/); | |
| assert.doesNotMatch(html, /min-\[1760px\]:flex/); | |
| }); | |
| it('lets the scrollable controls use the remaining desktop height without clipping the footer', () => { | |
| const html = renderGenerationForm(); | |
| assert.match(html, /flex min-h-0 flex-1 flex-col overflow-hidden/); | |
| assert.match(html, /literary-scrollbar min-h-0 flex-1 space-y-2\.5 overflow-y-auto p-4 pb-4/); | |
| assert.match(html, /border-border bg-card flex shrink-0 border-t p-3/); | |
| assert.doesNotMatch(html, /lg:max-h-\[calc\(100%-9\.75rem\)\]/); | |
| }); | |
| }); | |
| describe('GenerationForm inactive state', { concurrency: false }, () => { | |
| it('only shows the high-resolution streaming recommendation for the active form', () => { | |
| const options = { size: 'landscape' as const, streamMode: 'non_stream' as const }; | |
| const activeHtml = renderGenerationForm(options); | |
| const inactiveHtml = renderGenerationForm({ ...options, isActive: false }); | |
| assert.match(activeHtml, /当前是 high 质量大尺寸请求/); | |
| assert.doesNotMatch(inactiveHtml, /当前是 high 质量大尺寸请求/); | |
| }); | |
| it('only applies profile-driven corrections after the form becomes active', async () => { | |
| const countCorrections: number[][] = []; | |
| const partialImageCorrections: number[] = []; | |
| const streamModeCorrections: string[] = []; | |
| const sizeCorrections: string[] = []; | |
| const props = createGenerationFormProps({ | |
| isActive: false, | |
| n: [0], | |
| model: 'gpt-image-1', | |
| size: 'custom', | |
| partialImages: 0, | |
| streamMode: 'auto', | |
| streamingStrategy: 'off' | |
| }); | |
| props.setN = (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.setSize = (nextSize) => { | |
| sizeCorrections.push(typeof nextSize === 'function' ? nextSize('custom') : nextSize); | |
| }; | |
| const view = await renderInClientDom( | |
| <I18nProvider> | |
| <GenerationForm {...props} /> | |
| </I18nProvider> | |
| ); | |
| try { | |
| assert.deepEqual(countCorrections, []); | |
| assert.deepEqual(partialImageCorrections, []); | |
| assert.deepEqual(streamModeCorrections, []); | |
| assert.deepEqual(sizeCorrections, []); | |
| await view.render( | |
| <I18nProvider> | |
| <GenerationForm {...props} isActive /> | |
| </I18nProvider> | |
| ); | |
| assert.deepEqual(countCorrections, [[1]]); | |
| assert.deepEqual(partialImageCorrections, [1]); | |
| assert.deepEqual(streamModeCorrections, ['non_stream']); | |
| assert.deepEqual(sizeCorrections, ['auto']); | |
| } finally { | |
| await view.cleanup(); | |
| } | |
| }); | |
| it('runs generation inspiration actions through the rendered footer controls', async () => { | |
| const savedPrompts: string[] = []; | |
| const appliedPrompts: string[] = []; | |
| const props = createGenerationFormProps({ prompt: ' 当前生成灵感 ' }); | |
| props.onSaveInspiration = (prompt) => savedPrompts.push(prompt); | |
| props.onPickRandomInspiration = () => ' 随机生成灵感 '; | |
| props.setPrompt = (nextPrompt) => { | |
| appliedPrompts.push(typeof nextPrompt === 'function' ? nextPrompt('') : nextPrompt); | |
| }; | |
| const view = await renderInClientDom( | |
| <I18nProvider> | |
| <GenerationForm {...props} /> | |
| </I18nProvider> | |
| ); | |
| 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 generation save inspiration button'); | |
| assert.ok(randomButton, 'missing generation random inspiration button'); | |
| await view.click(saveButton); | |
| await view.click(randomButton); | |
| assert.deepEqual(savedPrompts, [' 当前生成灵感 ']); | |
| assert.deepEqual(appliedPrompts, ['随机生成灵感']); | |
| } finally { | |
| await view.cleanup(); | |
| } | |
| }); | |
| it('routes random inspiration through the rendered batch footer control', async () => { | |
| const appliedBatchPrompts: string[] = []; | |
| const props = createGenerationFormProps({ | |
| currentMode: 'batch', | |
| batchPromptText: '当前批量任务' | |
| }); | |
| props.onPickRandomInspiration = () => ' 随机批量灵感 '; | |
| props.setBatchPromptText = (nextPrompt) => { | |
| appliedBatchPrompts.push(typeof nextPrompt === 'function' ? nextPrompt('') : nextPrompt); | |
| }; | |
| const view = await renderInClientDom( | |
| <I18nProvider> | |
| <GenerationForm {...props} /> | |
| </I18nProvider> | |
| ); | |
| try { | |
| const randomButton = [...view.container.querySelectorAll('button')].find((button) => | |
| button.textContent?.includes('随机套用') | |
| ); | |
| assert.ok(randomButton, 'missing batch random inspiration button'); | |
| await view.click(randomButton); | |
| assert.deepEqual(appliedBatchPrompts, ['随机批量灵感']); | |
| } finally { | |
| await view.cleanup(); | |
| } | |
| }); | |
| }); | |
| describe('GenerationForm advanced groups', () => { | |
| it('keeps translated preset labels inside responsive control cells', () => { | |
| const html = renderGenerationForm(); | |
| assert.match(html, /grid grid-cols-2 gap-1.5/); | |
| assert.match(html, /max-w-full min-w-0 text-center leading-4 break-words whitespace-normal/); | |
| assert.match(html, /radio-group-item-content\]\]:min-w-0/); | |
| assert.match(html, /radio-group-item-content\]\]:overflow-hidden/); | |
| assert.doesNotMatch(html, /radio-group-item-content\]\]:overflow-visible/); | |
| assert.doesNotMatch(html, /2xl:text-xs/); | |
| assert.doesNotMatch(html, /truncate text-center leading-4/); | |
| assert.doesNotMatch(html, /lg:hidden 2xl:block/); | |
| assert.doesNotMatch(html, /2xl:grid-cols-5/); | |
| assert.doesNotMatch(html, /2xl:grid-cols-6/); | |
| }); | |
| it('shows task-specific descriptions in the mode segmented control', () => { | |
| const html = renderGenerationForm(); | |
| for (const label of ['从灵感开始', '基于参考图编辑', '一次多张', '套用已有提示词']) { | |
| assert.match(html, new RegExp(label)); | |
| } | |
| }); | |
| it('keeps the full professional accordion available on desktop and mobile', () => { | |
| const html = renderGenerationForm({ defaultAdvancedTab: 'route' }); | |
| assert.match( | |
| html, | |
| /<div class="border-border bg-muted\/20 rounded-md border"><button[^>]*aria-controls="generation-advanced-panel"/ | |
| ); | |
| assert.doesNotMatch( | |
| html, | |
| /<div class="[^"]*lg:hidden[^"]*"><button[^>]*aria-controls="generation-advanced-panel"/ | |
| ); | |
| }); | |
| it('labels the collapsed mobile advanced drawer as easy mode', () => { | |
| const html = renderGenerationForm(); | |
| assert.match(html, /省心模式/); | |
| assert.match(html, /常用参数已放在基础设置里/); | |
| assert.doesNotMatch(html, /block truncate text-xs font-normal/); | |
| }); | |
| it('translates the default backend into a user-facing route label near submit', () => { | |
| const html = renderGenerationForm(); | |
| assert.match(html, /默认线路/); | |
| assert.match(html, /请求 1 张图片/); | |
| }); | |
| it('labels the expanded mobile advanced drawer as professional mode', () => { | |
| const html = renderGenerationForm({ defaultAdvancedOpen: true }); | |
| assert.match(html, /专业模式/); | |
| assert.match(html, /清晰度: 高, 输出格式: PNG, 服务端默认/); | |
| }); | |
| it('renders route controls in a separate professional tab', () => { | |
| const html = renderGenerationForm({ defaultAdvancedOpen: true, defaultAdvancedTab: 'route' }); | |
| for (const label of ['输出', '模型', '流式', '路由']) { | |
| assert.match(html, new RegExp(label)); | |
| } | |
| assert.match(html, /image-backend-select/); | |
| assert.match(html, /streaming-strategy-select/); | |
| assert.doesNotMatch(html, /model-select/); | |
| }); | |
| it('explains route choices that affect stability and cost', () => { | |
| const html = renderGenerationForm({ | |
| defaultAdvancedOpen: true, | |
| defaultAdvancedTab: 'route', | |
| upstreamProfileMixed: true | |
| }); | |
| assert.match(html, /影响说明/); | |
| assert.match(html, /服务端默认会沿用当前部署配置/); | |
| assert.match(html, /当前服务端渠道包含不同上游模式/); | |
| assert.match(html, /自动或服务端默认会优先使用当前推荐的流式策略/); | |
| assert.match(html, /费用主要由模型、尺寸、数量和预览图数量决定/); | |
| }); | |
| it('explains the resolved server default streaming strategy', () => { | |
| const offHtml = renderGenerationForm({ | |
| defaultAdvancedOpen: true, | |
| defaultAdvancedTab: 'route', | |
| streamingStrategy: 'server-default', | |
| defaultStreamingStrategy: 'off' | |
| }); | |
| const forceHtml = renderGenerationForm({ | |
| defaultAdvancedOpen: true, | |
| defaultAdvancedTab: '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 = renderGenerationForm({ | |
| defaultAdvancedOpen: true, | |
| defaultAdvancedTab: 'route', | |
| allowResponsesImageBackend: false | |
| }); | |
| assert.match(html, /当前运行时或默认服务器渠道未开放 Responses image_generation/); | |
| assert.doesNotMatch(html, /GPT 顶层模型/); | |
| }); | |
| it('blocks Responses generation until a top-level model is available', () => { | |
| const html = renderGenerationForm({ | |
| defaultAdvancedOpen: true, | |
| defaultAdvancedTab: 'route', | |
| imageBackend: 'responses-image-generation', | |
| hasDefaultResponsesModel: false, | |
| responsesModel: '' | |
| }); | |
| assert.match(html, /Responses image_generation 需要填写 GPT 顶层模型/); | |
| assert.match(html, /<button[^>]*disabled=""[^>]*>[\s\S]*生成图像[\s\S]*<\/button>/); | |
| }); | |
| it('allows Responses generation when the runtime has a default top-level model', () => { | |
| const html = renderGenerationForm({ | |
| defaultAdvancedOpen: true, | |
| defaultAdvancedTab: 'route', | |
| imageBackend: 'responses-image-generation', | |
| hasDefaultResponsesModel: true, | |
| responsesModel: '' | |
| }); | |
| assert.match(html, /GPT 顶层模型/); | |
| assert.doesNotMatch(html, /Responses image_generation 需要填写 GPT 顶层模型/); | |
| }); | |
| it('keeps the model selector out of the route group', () => { | |
| const html = renderGenerationForm({ defaultAdvancedOpen: true, defaultAdvancedTab: 'model' }); | |
| assert.match(html, /model-select/); | |
| assert.doesNotMatch(html, /image-backend-select/); | |
| }); | |
| it('keeps OpenAI-compatible generation controls within its upstream profile', () => { | |
| const streamHtml = renderGenerationForm({ defaultAdvancedOpen: true, defaultAdvancedTab: 'stream' }); | |
| const outputHtml = renderGenerationForm({ defaultAdvancedOpen: true, defaultAdvancedTab: 'output' }); | |
| assert.doesNotMatch(streamHtml, /partial-0/); | |
| assert.match(streamHtml, /partial-1/); | |
| assert.match(streamHtml, /partial-3/); | |
| assert.doesNotMatch(streamHtml, /partial-4/); | |
| assert.match(outputHtml, /n-3/); | |
| assert.match(outputHtml, /n-10/); | |
| assert.match(outputHtml, /n-8/); | |
| assert.match(readButtonById(outputHtml, 'bg-transparent'), /disabled=""/); | |
| }); | |
| it('renders Matsca generation controls when the active upstream profile allows them', () => { | |
| const streamHtml = renderGenerationForm({ | |
| defaultAdvancedOpen: true, | |
| defaultAdvancedTab: 'stream', | |
| upstreamProfile: IMAGE_UPSTREAM_PROFILES.matsca | |
| }); | |
| const outputHtml = renderGenerationForm({ | |
| defaultAdvancedOpen: true, | |
| defaultAdvancedTab: 'output', | |
| upstreamProfile: IMAGE_UPSTREAM_PROFILES.matsca | |
| }); | |
| assert.match(streamHtml, /partial-0/); | |
| assert.match(streamHtml, /partial-4/); | |
| assert.match(outputHtml, /n-3/); | |
| assert.doesNotMatch(outputHtml, /n-8/); | |
| assert.doesNotMatch(readButtonById(outputHtml, 'bg-transparent'), /disabled=""/); | |
| }); | |
| it('intersects Matsca partial image options with the Responses backend contract', () => { | |
| const html = renderGenerationForm({ | |
| defaultAdvancedOpen: true, | |
| defaultAdvancedTab: 'stream', | |
| imageBackend: 'responses-image-generation', | |
| upstreamProfile: IMAGE_UPSTREAM_PROFILES.matsca | |
| }); | |
| assert.doesNotMatch(html, /partial-0/); | |
| assert.match(html, /partial-1/); | |
| assert.match(html, /partial-3/); | |
| assert.doesNotMatch(html, /partial-4/); | |
| }); | |
| it('renders profile-aware high resolution size presets', () => { | |
| const openAiHtml = renderGenerationForm(); | |
| const matscaHtml = renderGenerationForm({ upstreamProfile: IMAGE_UPSTREAM_PROFILES.matsca }); | |
| assert.match(openAiHtml, /id="size-wide-4k"/); | |
| assert.doesNotMatch(openAiHtml, /id="size-square-4k"/); | |
| assert.match(matscaHtml, /id="size-square-4k"/); | |
| }); | |
| it('disables random inspiration when no saved prompt is available', () => { | |
| const html = renderGenerationForm({ canApplyRandomInspiration: false }); | |
| assert.match(html, /随机套用/); | |
| assert.match(html, /<button[^>]*disabled=""[^>]*>[\s\S]*随机套用[\s\S]*<\/button>/); | |
| assert.doesNotMatch(html, /夏日窗边的奶油色房间/); | |
| }); | |
| it('does not render built-in prompt style chips before the user enters real content', () => { | |
| const html = renderGenerationForm(); | |
| assert.doesNotMatch(html, /风格偏好/); | |
| assert.doesNotMatch(html, /胶片感/); | |
| assert.doesNotMatch(html, /奶油色/); | |
| assert.doesNotMatch(html, /夏日窗边/); | |
| }); | |
| }); | |
| describe('GenerationForm batch mode', () => { | |
| it('uses the visible batch prompt text for footer prompt actions', () => { | |
| assert.deepEqual( | |
| resolveGenerationFooterPromptTarget({ | |
| currentMode: 'batch', | |
| prompt: 'hidden single prompt', | |
| batchPromptText: 'first batch prompt\nsecond batch prompt' | |
| }), | |
| { | |
| value: 'first batch prompt\nsecond batch prompt', | |
| isEmpty: false | |
| } | |
| ); | |
| assert.deepEqual( | |
| resolveGenerationFooterPromptTarget({ | |
| currentMode: 'generate', | |
| prompt: 'visible single prompt', | |
| batchPromptText: 'hidden batch prompt' | |
| }), | |
| { | |
| value: 'visible single prompt', | |
| isEmpty: false | |
| } | |
| ); | |
| }); | |
| it('renders a real batch prompt list only in batch mode', () => { | |
| const html = renderGenerationForm({ currentMode: 'batch' }); | |
| assert.match(html, /批量提示词列表/); | |
| assert.match(html, /2 条任务/); | |
| assert.match(html, /batch-prompt-list/); | |
| assert.doesNotMatch(html, /id="prompt"/); | |
| }); | |
| it('shows a low-interruption task summary for batch jobs', () => { | |
| const html = renderGenerationForm({ currentMode: 'batch' }); | |
| assert.match(html, /batch-task-summary/); | |
| assert.match(html, /任务摘要/); | |
| assert.match(html, /每一行会生成一张图/); | |
| assert.match(html, /失败后处理/); | |
| assert.match(html, /生成动态/); | |
| assert.match(html, /不占用中央单张预览/); | |
| }); | |
| it('shows a reusable failed-task entry for partial batch failures', () => { | |
| const html = renderGenerationForm({ | |
| currentMode: 'batch', | |
| failedBatchPrompts: ['用户真实提示词 B'] | |
| }); | |
| assert.match(html, /上次批量有 1 条未完成/); | |
| assert.match(html, /只保留失败项/); | |
| }); | |
| it('keeps the batch prompt list out of the default generate mode', () => { | |
| const html = renderGenerationForm({ failedBatchPrompts: ['用户真实提示词 B'] }); | |
| assert.doesNotMatch(html, /batch-prompt-list/); | |
| assert.doesNotMatch(html, /batch-task-summary/); | |
| assert.doesNotMatch(html, /只保留失败项/); | |
| }); | |
| it('shows a real pause action while a batch is loading', () => { | |
| const html = renderGenerationForm({ | |
| currentMode: 'batch', | |
| canPauseBatch: true | |
| }); | |
| assert.match(html, /暂停批量/); | |
| assert.match(html, /已开始的任务会完成,未开始的任务会保留为失败项再重试。/); | |
| }); | |
| it('keeps the pause action renderable when the handler is omitted', () => { | |
| const html = renderGenerationForm({ | |
| currentMode: 'batch', | |
| canPauseBatch: true, | |
| omitPauseHandler: true | |
| }); | |
| assert.match(html, /暂停批量/); | |
| }); | |
| it('switches the pause action into requested state after pause is requested', () => { | |
| const html = renderGenerationForm({ | |
| currentMode: 'batch', | |
| canPauseBatch: true, | |
| isBatchPauseRequested: true | |
| }); | |
| assert.match(html, /暂停中/); | |
| assert.match(html, /disabled=""/); | |
| }); | |
| it('renders an explicit parallel batch toggle in stream settings', () => { | |
| const html = renderGenerationForm({ | |
| currentMode: 'batch', | |
| defaultAdvancedOpen: true, | |
| defaultAdvancedTab: 'stream', | |
| allowStreamingBatch: true, | |
| enableParallelBatch: true | |
| }); | |
| assert.match(html, /并发批量/); | |
| assert.match(html, /多张图或多条提示词会按当前渠道容量并发执行/); | |
| assert.match(html, /id="parallel-batch-enabled"/); | |
| assert.match(html, /aria-checked="true"/); | |
| }); | |
| it('keeps the parallel batch toggle disabled for a single image request', () => { | |
| const html = renderGenerationForm({ | |
| defaultAdvancedOpen: true, | |
| defaultAdvancedTab: 'stream', | |
| allowStreamingBatch: true, | |
| enableParallelBatch: true | |
| }); | |
| assert.match(html, /选择至少 2 张图片或 2 条提示词后可启用并发/); | |
| assert.match(html, /id="parallel-batch-enabled"/); | |
| assert.match(html, /aria-checked="false"/); | |
| assert.match(html, /disabled=""/); | |
| }); | |
| it('keeps the parallel batch toggle disabled when streaming strategy is off', () => { | |
| const html = renderGenerationForm({ | |
| currentMode: 'batch', | |
| defaultAdvancedOpen: true, | |
| defaultAdvancedTab: 'stream', | |
| allowStreamingBatch: true, | |
| enableParallelBatch: true, | |
| streamingStrategy: 'off' | |
| }); | |
| assert.match(html, /并发批量需要流式模式;非流式会保持顺序执行。/); | |
| assert.match(html, /id="parallel-batch-enabled"/); | |
| assert.match(html, /aria-checked="false"/); | |
| assert.match(html, /disabled=""/); | |
| }); | |
| it('keeps the parallel batch toggle disabled when the server default streaming strategy is off', () => { | |
| const html = renderGenerationForm({ | |
| currentMode: 'batch', | |
| defaultAdvancedOpen: true, | |
| defaultAdvancedTab: 'stream', | |
| allowStreamingBatch: true, | |
| enableParallelBatch: true, | |
| defaultStreamingStrategy: 'off' | |
| }); | |
| assert.match(html, /并发批量需要流式模式;非流式会保持顺序执行。/); | |
| assert.match(html, /id="parallel-batch-enabled"/); | |
| assert.match(html, /aria-checked="false"/); | |
| assert.match(html, /disabled=""/); | |
| }); | |
| }); | |