// @vitest-environment jsdom import { readFileSync } from 'node:fs'; import { cleanup, render, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { afterEach, describe, expect, test, vi } from 'vitest'; import { ResourceCanvasAssetGenerationPanelView } from '../src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView'; import type { ResourceCanvasAssetToolAction } from '../src/features/resource-canvas/resourceCanvasBottomToolbarModel'; import { ResourceCanvasGenerationPanelView } from '../src/features/resource-canvas/ResourceCanvasGenerationPanelView'; import { RESOURCE_CANVAS_GENERATION_PANEL_MAX_WIDTH, RESOURCE_CANVAS_GENERATION_PANEL_VIEWPORT_INSET, } from '../src/features/resource-canvas/resourceCanvasGenerationVisibilityModel'; import { repoPath } from './repoPath'; afterEach(cleanup); const imageAction: ResourceCanvasAssetToolAction = { id: 'generate-image', route: 'asset', label: '生成图片', assetKind: 'image', audioKind: null, assetName: 'AI 生成图片', promptPlaceholder: '今天想生成什么画面?', adjustableDimensions: true, aspectRatio: '1:1', imageSize: '1K', requiresIconSpecReference: false, writesIconSpecReference: false, }; describe('生成浮层的面板外观与高度合同', () => { test('浮层复用模态同一套面板 chrome 类,不另造一套外观', () => { render( undefined} onClose={() => undefined} />, ); const panel = screen.getByRole('dialog', { name: '生成图片' }); // 少了 `game-approval-dialog`,浮层就会变成没有背景/边框、header 挤在一起的一块裸容器。 expect(panel.classList.contains('game-approval-dialog')).toBe(true); expect(panel.classList.contains('game-resource-generation-dialog')).toBe( true, ); expect( panel.classList.contains('resource-canvas-generation-floating-panel'), ).toBe(true); // 位置与高度上界都由宿主按真实矩形给:内联样式必须原样落到面板上。 expect(panel.style.top).toBe('346px'); expect(panel.style.left).toBe('120px'); expect(panel.style.maxHeight).toBe('348px'); }); test('音频入口的浮层同样带 chrome 类,并复用宿主给的提交身份重试', async () => { const user = userEvent.setup(); const onSubmit = vi.fn(async () => undefined); render( undefined} />, ); const panel = screen.getByRole('dialog', { name: '生成背景音乐' }); expect(panel.classList.contains('game-approval-dialog')).toBe(true); expect( panel.classList.contains('resource-canvas-generation-floating-panel'), ).toBe(true); // 收起时保存的草稿要灌回来:不是空表单。 expect( within(panel).getByLabelText('生成提示词').getAttribute('value'), ).toBeNull(); await user.click( within(panel).getByRole('button', { name: '生成背景音乐' }), ); // 幂等重试:命中宿主记下的同一对 operationId / 幂等键,不铸造新的付费生成。 expect(onSubmit).toHaveBeenCalledTimes(1); expect(onSubmit.mock.calls[0]?.[0]).toMatchObject({ kind: 'background-music', operationId: 'operation-bound', idempotencyKey: 'key-bound', prompt: '轻快的八音盒', }); }); }); /** * 结构 CSS 钉子:真实浏览器 1280x720 上浮层曾经「面板垂到底栏下面、提交按钮点不到」, * 而且第一版浮层只有 `game-resource-generation-dialog` 一个类,连背景边框都没有。 * 这些是几何模型测不到的部分,所以直接钉住样式文件里的关键声明。 */ describe('生成浮层样式结构', () => { const panelCss = () => readFileSync( repoPath( 'apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasGenerationPanel.css', ), 'utf8', ); test('浮层选择器带上面板 chrome 类,并限制自身滚动与兜底上界', () => { const css = panelCss(); expect(css).toContain( '.game-approval-dialog.resource-canvas-generation-floating-panel {', ); // 锚点给的是占位卡中心:少了这行浮层会整体右偏半个面板宽(浏览器实测 x287 vs 卡中心 286)。 expect(css).toContain('transform: translateX(-50%);'); expect(css).toContain('overflow-y: auto;'); expect(css).toContain('overscroll-behavior: contain;'); // 兜底上界必须在:内联 maxHeight 拿不到几何时也不能整块垂出画布。 expect(css).toMatch( /\.game-approval-dialog\.resource-canvas-generation-floating-panel \{[\s\S]*?max-height:/u, ); }); test('动作行固定在面板底部,滚动内容不会把提交按钮顶出可视区', () => { const css = panelCss(); expect(css).toMatch( /\.game-approval-dialog\.resource-canvas-generation-floating-panel[\s\S]*?\.game-resource-generation-actions \{[\s\S]*?position: sticky;/u, ); expect(css).toMatch( /\.game-resource-generation-actions \{[\s\S]*?bottom: 0;[\s\S]*?background:/u, ); }); /** * 宿主算「水平收口」用的是模型里那对常量(浮层最大宽 + 左右内缩),CSS 这里是同一对数的另一份 * 写法:两边必须一致,否则收口算的是 560 宽、画出来的却是另一个宽度,卡片靠边时又会露出去。 */ test('宽度口径与宿主算水平收口的常量同源', () => { expect(panelCss()).toContain( `width: min(${RESOURCE_CANVAS_GENERATION_PANEL_MAX_WIDTH}px, calc(100% - ${ RESOURCE_CANVAS_GENERATION_PANEL_VIEWPORT_INSET * 2 }px));`, ); }); });