Switch to VectorEngine gpt-image-2 and edits
Replace uses of the legacy `gpt-image-2-all` model with `gpt-image-2` and standardize image workflows: no-reference generation uses POST /v1/images/generations, any-reference flows use POST /v1/images/edits with multipart `image` parts. Update SKILLs, generation scripts, decision logs, and docs to reflect the contract change and edits-vs-generations guidance. Apply corresponding changes across backend (api-server match3d/puzzle modules, openai image adapter, mappers, telemetry, spacetime client/module), frontend components and services (Match3D, Puzzle, CreativeImageInputPanel, runtime shells), and add new spritesheet/parser files and tests. Also add media/logo.png. These changes align repository code and documentation with the VectorEngine image API contract and update generation/upload handling (green-screen -> alpha processing, spritesheet handling, and related tests).
This commit is contained in:
@@ -1,6 +1,13 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { act, fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import type { PuzzleRunSnapshot } from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
||||
@@ -21,6 +28,31 @@ vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/puzzle-runtime/puzzleUiSpritesheetParser', async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import('../../services/puzzle-runtime/puzzleUiSpritesheetParser')
|
||||
>('../../services/puzzle-runtime/puzzleUiSpritesheetParser');
|
||||
return {
|
||||
...actual,
|
||||
loadPuzzleUiSpritesheetLayout: vi.fn(async () => ({
|
||||
width: 32,
|
||||
height: 24,
|
||||
regions: {
|
||||
back: { x: 3, y: 2, width: 4, height: 4 },
|
||||
settings: { x: 24, y: 3, width: 5, height: 5 },
|
||||
next: { x: 11, y: 11, width: 10, height: 5 },
|
||||
hint: { x: 2, y: 20, width: 5, height: 3 },
|
||||
reference: { x: 13, y: 19, width: 6, height: 4 },
|
||||
freezeTime: { x: 25, y: 20, width: 5, height: 4 },
|
||||
},
|
||||
hitRegions: {
|
||||
back: { x: 4, y: 3, width: 2, height: 2 },
|
||||
hint: { x: 3, y: 21, width: 3, height: 1 },
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../ResolvedAssetImage', () => ({
|
||||
ResolvedAssetImage: ({
|
||||
src,
|
||||
@@ -183,7 +215,7 @@ test('指针拖拽时会触发拖拽提交并在松开时落子', () => {
|
||||
allTilesResolved: false,
|
||||
pieces: clearedRun.currentLevel!.board.pieces.map((piece) =>
|
||||
piece.pieceId === 'piece-0'
|
||||
? {...piece, currentRow: 0, currentCol: 0}
|
||||
? { ...piece, currentRow: 0, currentCol: 0 }
|
||||
: piece,
|
||||
),
|
||||
},
|
||||
@@ -200,11 +232,15 @@ test('指针拖拽时会触发拖拽提交并在松开时落子', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
const piece = container.querySelector('[data-piece-id="piece-0"]') as HTMLElement | null;
|
||||
const piece = container.querySelector(
|
||||
'[data-piece-id="piece-0"]',
|
||||
) as HTMLElement | null;
|
||||
if (!piece) {
|
||||
throw new Error('缺少测试拼图片');
|
||||
}
|
||||
const board = container.querySelector('[data-testid="puzzle-board"]') as HTMLElement | null;
|
||||
const board = container.querySelector(
|
||||
'[data-testid="puzzle-board"]',
|
||||
) as HTMLElement | null;
|
||||
if (!board) {
|
||||
throw new Error('缺少测试棋盘');
|
||||
}
|
||||
@@ -213,17 +249,18 @@ test('指针拖拽时会触发拖拽提交并在松开时落子', () => {
|
||||
configurable: true,
|
||||
value: requestAnimationFrame,
|
||||
});
|
||||
board.getBoundingClientRect = () => ({
|
||||
x: 0,
|
||||
y: 0,
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 300,
|
||||
bottom: 300,
|
||||
width: 300,
|
||||
height: 300,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect);
|
||||
board.getBoundingClientRect = () =>
|
||||
({
|
||||
x: 0,
|
||||
y: 0,
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 300,
|
||||
bottom: 300,
|
||||
width: 300,
|
||||
height: 300,
|
||||
toJSON: () => ({}),
|
||||
}) as DOMRect;
|
||||
|
||||
act(() => {
|
||||
dispatchPointerEvent(piece, 'pointerdown', {
|
||||
@@ -259,7 +296,7 @@ test('指针拖拽时会触发拖拽提交并在松开时落子', () => {
|
||||
|
||||
expect(onDragPiece).toHaveBeenCalledTimes(1);
|
||||
expect(onDragPiece).toHaveBeenCalledWith(
|
||||
expect.objectContaining({pieceId: 'piece-0'}),
|
||||
expect.objectContaining({ pieceId: 'piece-0' }),
|
||||
);
|
||||
|
||||
Object.defineProperty(window, 'requestAnimationFrame', {
|
||||
@@ -468,9 +505,7 @@ test('拖拽合并大块时底层单格不显示选中色块', () => {
|
||||
'[data-piece-id="piece-0"]',
|
||||
) as HTMLElement | null;
|
||||
expect(basePiece?.className).toContain('puzzle-runtime-piece--merged');
|
||||
expect(basePiece?.className).not.toContain(
|
||||
'puzzle-runtime-piece--selected',
|
||||
);
|
||||
expect(basePiece?.className).not.toContain('puzzle-runtime-piece--selected');
|
||||
|
||||
unmount();
|
||||
Object.defineProperty(window, 'requestAnimationFrame', {
|
||||
@@ -657,7 +692,7 @@ test('首次退出引导的作品改造按钮进入改造流程', () => {
|
||||
expect(screen.queryByRole('dialog')).toBeNull();
|
||||
});
|
||||
|
||||
test('顶部不显示作者,关卡标题和倒计时更紧凑', () => {
|
||||
test('顶部不显示作者,关卡标题和倒计时使用游戏铭牌结构', () => {
|
||||
const runWithoutNext: PuzzleRunSnapshot = {
|
||||
...clearedRun,
|
||||
recommendedNextProfileId: null,
|
||||
@@ -681,12 +716,26 @@ test('顶部不显示作者,关卡标题和倒计时更紧凑', () => {
|
||||
expect(screen.queryByText('测试作者')).toBeNull();
|
||||
expect(screen.getByText('第 1 关')).toBeTruthy();
|
||||
expect(screen.getByText('潮雾拼图')).toBeTruthy();
|
||||
const levelLogo = screen.getByTestId(
|
||||
'puzzle-runtime-level-logo',
|
||||
) as HTMLImageElement;
|
||||
expect(levelLogo.getAttribute('src')).toContain('logo.png');
|
||||
expect(levelLogo.closest('.puzzle-runtime-level-logo')).toBeTruthy();
|
||||
expect(document.querySelector('.puzzle-runtime-level-mascot')).toBeNull();
|
||||
expect(timer.closest('.puzzle-runtime-timer-card')).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText('潮雾拼图').closest('.puzzle-runtime-level-title-card'),
|
||||
).toBeTruthy();
|
||||
expect(timer.className).toContain('puzzle-runtime-timer');
|
||||
expect(timer.className).toContain('text-lg');
|
||||
expect(timer.className).not.toContain('text-2xl');
|
||||
expect(hintButton.className).toContain('h-16');
|
||||
expect(referenceButton.className).toContain('h-16');
|
||||
expect(freezeButton.className).toContain('h-16');
|
||||
expect(hintButton.textContent).not.toContain('提示');
|
||||
expect(referenceButton.textContent).not.toContain('原图');
|
||||
expect(freezeButton.textContent).not.toContain('冻结');
|
||||
expect(hintButton.parentElement?.className).toContain('grid-cols-3');
|
||||
expect(hintButton.parentElement?.className).not.toContain(
|
||||
'puzzle-runtime-toolbar',
|
||||
);
|
||||
expect(screen.queryByText('等待下一关候选')).toBeNull();
|
||||
});
|
||||
|
||||
@@ -720,6 +769,167 @@ test('运行态优先把关卡 UI 背景渲染为舞台背景', () => {
|
||||
expect(backgroundImage).toBeTruthy();
|
||||
});
|
||||
|
||||
test('运行态优先把关卡背景图渲染为舞台背景', async () => {
|
||||
const runWithLevelBackground: PuzzleRunSnapshot = {
|
||||
...clearedRun,
|
||||
currentLevel: {
|
||||
...clearedRun.currentLevel!,
|
||||
status: 'playing',
|
||||
coverImageSrc: '/generated-puzzle-assets/session/cover.png',
|
||||
uiBackgroundImageSrc: '/generated-puzzle-assets/session/ui/legacy.png',
|
||||
levelBackgroundImageSrc:
|
||||
'/generated-puzzle-assets/session/level-background/background.png',
|
||||
uiSpritesheetImageSrc:
|
||||
'/generated-puzzle-assets/session/ui-spritesheet/sheet.png',
|
||||
remainingMs: 300_000,
|
||||
timeLimitMs: 300_000,
|
||||
},
|
||||
};
|
||||
|
||||
const { container } = renderPuzzleRuntime(
|
||||
<PuzzleRuntimeShell
|
||||
run={runWithLevelBackground}
|
||||
onBack={vi.fn()}
|
||||
onSwapPieces={vi.fn()}
|
||||
onDragPiece={vi.fn()}
|
||||
onAdvanceNextLevel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
container.querySelector(
|
||||
'img[src="/generated-puzzle-assets/session/level-background/background.png"]',
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
container.querySelector(
|
||||
'img[src="/generated-puzzle-assets/session/ui/legacy.png"]',
|
||||
),
|
||||
).toBeNull();
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen
|
||||
.getByRole('button', { name: '提示' })
|
||||
.querySelector('[data-puzzle-ui-sprite-hit-zone="hint"]'),
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
test('运行态用 UI spritesheet 原图检测矩形裁切返回设置下一关和道具按钮', async () => {
|
||||
const runWithSpritesheet: PuzzleRunSnapshot = {
|
||||
...clearedRun,
|
||||
currentLevel: {
|
||||
...clearedRun.currentLevel!,
|
||||
status: 'cleared',
|
||||
coverImageSrc: '/generated-puzzle-assets/session/cover.png',
|
||||
uiSpritesheetImageSrc:
|
||||
'/generated-puzzle-assets/session/ui-spritesheet/sheet.png',
|
||||
remainingMs: 120_000,
|
||||
timeLimitMs: 300_000,
|
||||
},
|
||||
nextLevelMode: 'sameWork',
|
||||
nextLevelProfileId: 'profile-1',
|
||||
};
|
||||
|
||||
renderPuzzleRuntime(
|
||||
<PuzzleRuntimeShell
|
||||
run={runWithSpritesheet}
|
||||
onBack={vi.fn()}
|
||||
onSwapPieces={vi.fn()}
|
||||
onDragPiece={vi.fn()}
|
||||
onAdvanceNextLevel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByRole('button', { name: '下一关' });
|
||||
|
||||
expect(
|
||||
screen
|
||||
.getByRole('button', { name: '返回上一页' })
|
||||
.querySelector('[data-puzzle-ui-sprite="back"]'),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole('button', { name: '返回上一页' }).className,
|
||||
).toContain('puzzle-runtime-icon-button--sprite');
|
||||
expect(
|
||||
screen.getByRole('button', { name: '返回上一页' }).className,
|
||||
).toContain('puzzle-runtime-icon-button--precise-hit');
|
||||
expect(
|
||||
screen
|
||||
.getByRole('button', { name: '返回上一页' })
|
||||
.querySelector('[data-puzzle-ui-sprite-hit-zone="back"]'),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole('button', { name: '返回上一页' }).className,
|
||||
).not.toContain('rounded-full');
|
||||
expect(
|
||||
screen
|
||||
.getByRole('button', { name: '打开拼图设置' })
|
||||
.querySelector('[data-puzzle-ui-sprite="settings"]'),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole('button', { name: '打开拼图设置' }).className,
|
||||
).toContain('puzzle-runtime-icon-button--sprite');
|
||||
expect(
|
||||
screen.getByRole('button', { name: '打开拼图设置' }).className,
|
||||
).toContain('puzzle-runtime-icon-button--precise-hit');
|
||||
expect(
|
||||
screen.getByRole('button', { name: '打开拼图设置' }).className,
|
||||
).not.toContain('rounded-full');
|
||||
const nextSprite = screen
|
||||
.getByRole('button', { name: '下一关' })
|
||||
.querySelector('[data-puzzle-ui-sprite="next"]') as HTMLElement | null;
|
||||
expect(nextSprite).toBeTruthy();
|
||||
expect(nextSprite?.style.backgroundSize).toBe('320% 480%');
|
||||
expect(nextSprite?.style.backgroundPosition).toBe('50% 57.89473684210527%');
|
||||
expect(
|
||||
screen
|
||||
.getByRole('button', { name: '提示' })
|
||||
.querySelector('[data-puzzle-ui-sprite="hint"]'),
|
||||
).toBeTruthy();
|
||||
const hintHitZone = screen
|
||||
.getByRole('button', { name: '提示' })
|
||||
.querySelector('[data-puzzle-ui-sprite-hit-zone="hint"]') as HTMLElement | null;
|
||||
expect(hintHitZone).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '提示' }).className).toContain(
|
||||
'puzzle-runtime-sprite-tool-button--precise-hit',
|
||||
);
|
||||
expect(hintHitZone?.className).toContain(
|
||||
'puzzle-runtime-ui-sprite-hit-zone',
|
||||
);
|
||||
expect(hintHitZone?.style.left).toBe('20%');
|
||||
expect(hintHitZone?.style.top).toBe('33.33333333333333%');
|
||||
expect(hintHitZone?.style.width).toBe('60%');
|
||||
expect(hintHitZone?.style.height).toBe('33.33333333333333%');
|
||||
expect(
|
||||
screen
|
||||
.getByRole('button', { name: '提示' })
|
||||
.querySelector('[data-puzzle-ui-sprite="hint"]')?.className,
|
||||
).toContain('puzzle-runtime-bottom-ui-sprite');
|
||||
expect(
|
||||
screen
|
||||
.getByRole('button', { name: '提示' })
|
||||
.querySelector('[data-puzzle-ui-sprite="hint"]')?.className,
|
||||
).not.toContain('w-full');
|
||||
expect(screen.getByRole('button', { name: '提示' }).textContent).toBe('');
|
||||
const referenceSprite = screen
|
||||
.getByRole('button', { name: '原图' })
|
||||
.querySelector('[data-puzzle-ui-sprite="reference"]') as HTMLElement | null;
|
||||
expect(referenceSprite).toBeTruthy();
|
||||
expect(referenceSprite?.style.backgroundSize).toBe('533.3333333333333% 600%');
|
||||
expect(referenceSprite?.style.backgroundPosition).toBe('50% 95%');
|
||||
expect(referenceSprite?.style.aspectRatio).toBe('6 / 4');
|
||||
expect(referenceSprite?.className).toContain('puzzle-runtime-bottom-ui-sprite');
|
||||
expect(referenceSprite?.className).not.toContain('w-full');
|
||||
expect(screen.getByRole('button', { name: '原图' }).textContent).toBe('');
|
||||
expect(
|
||||
screen
|
||||
.getByRole('button', { name: '冻结' })
|
||||
.querySelector('[data-puzzle-ui-sprite="freezeTime"]'),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '冻结' }).textContent).toBe('');
|
||||
});
|
||||
|
||||
test('运行态在只有 UI 背景 objectKey 时仍渲染生成背景', () => {
|
||||
const runWithUiBackground: PuzzleRunSnapshot = {
|
||||
...clearedRun,
|
||||
@@ -994,6 +1204,36 @@ test('推荐页嵌入拼图时隐藏返回和设置里的退出入口', () => {
|
||||
expect(within(dialog).queryByText('保存并退出')).toBeNull();
|
||||
});
|
||||
|
||||
test('推荐页嵌入拼图通关结算使用页面级浮层避免卡片裁剪', () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
renderPuzzleRuntime(
|
||||
<div className="platform-recommend-runtime-viewport">
|
||||
<PuzzleRuntimeShell
|
||||
run={clearedRun}
|
||||
embedded
|
||||
hideExitControls
|
||||
onBack={vi.fn()}
|
||||
onSwapPieces={vi.fn()}
|
||||
onDragPiece={vi.fn()}
|
||||
onAdvanceNextLevel={vi.fn()}
|
||||
/>
|
||||
</div>,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1_400);
|
||||
});
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '通关完成' });
|
||||
const overlay = dialog.closest('.puzzle-runtime-modal-overlay');
|
||||
|
||||
expect(overlay?.className).toContain('puzzle-runtime-modal-overlay--fixed');
|
||||
expect(overlay?.closest('.platform-recommend-runtime-viewport')).toBeNull();
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('拼图棋盘使用贴近移动端边缘的正方形舞台承载切块', () => {
|
||||
const { container } = renderPuzzleRuntime(
|
||||
<PuzzleRuntimeShell
|
||||
@@ -1066,11 +1306,11 @@ test('合并块不叠加可见轮廓和单块阴影', () => {
|
||||
const mergedRun: PuzzleRunSnapshot = {
|
||||
...clearedRun,
|
||||
currentLevel: {
|
||||
...clearedRun.currentLevel!,
|
||||
status: 'playing',
|
||||
startedAtMs: Date.now(),
|
||||
coverImageSrc: '/puzzle.png',
|
||||
board: {
|
||||
...clearedRun.currentLevel!,
|
||||
status: 'playing',
|
||||
startedAtMs: Date.now(),
|
||||
coverImageSrc: '/puzzle.png',
|
||||
board: {
|
||||
...clearedRun.currentLevel!.board,
|
||||
allTilesResolved: false,
|
||||
mergedGroups: [
|
||||
@@ -1531,7 +1771,9 @@ test('失败弹窗支持重开当前关和续时确认', async () => {
|
||||
);
|
||||
|
||||
const failedDialog = screen.getByRole('dialog', { name: '关卡失败' });
|
||||
fireEvent.click(within(failedDialog).getByRole('button', { name: '重新开始' }));
|
||||
fireEvent.click(
|
||||
within(failedDialog).getByRole('button', { name: '重新开始' }),
|
||||
);
|
||||
expect(onRestartLevel).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.click(
|
||||
|
||||
Reference in New Issue
Block a user