修复项目资源预览刷新与运行提示
切换资源布局时复用现有预览与尺寸状态 精修提交后仅失效对应素材的卡片预览 避免资源编辑状态下显示运行不可用提示 补充资源预览与项目工作台回归测试
This commit is contained in:
@@ -855,6 +855,8 @@ export default function ProjectDevelopmentView({
|
||||
const [hiddenCommittedResourceId, setHiddenCommittedResourceId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [resourcePreviewVersionByResourceId, setResourcePreviewVersionByResourceId] =
|
||||
useState<Map<string, string>>(() => new Map());
|
||||
const [approvalMode, setApprovalMode] = useState<ApprovalMode>('strict');
|
||||
const [approvalDialogOpen, setApprovalDialogOpen] = useState(false);
|
||||
const [approvalNotice, setApprovalNotice] = useState('');
|
||||
@@ -1228,6 +1230,7 @@ export default function ProjectDevelopmentView({
|
||||
resources,
|
||||
canvasRef: resourceCanvasRef,
|
||||
eagerPreviewLimit: 12,
|
||||
previewVersionByResourceId: resourcePreviewVersionByResourceId,
|
||||
});
|
||||
const resourceCardSizeByResourceId = useMemo(
|
||||
() =>
|
||||
@@ -2147,6 +2150,7 @@ export default function ProjectDevelopmentView({
|
||||
setPendingResourceEditsError('');
|
||||
setPendingResourceEditActionErrors(new Map());
|
||||
setResourceEditServiceIdentityConfirmations(new Map());
|
||||
setResourcePreviewVersionByResourceId(new Map());
|
||||
resourceRecoveryPanelEpochRef.current += 1;
|
||||
setResourceRecoveryPanelOpen(false);
|
||||
pendingResourceEditActionIdsRef.current = new Set();
|
||||
@@ -3627,6 +3631,15 @@ export default function ProjectDevelopmentView({
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const committedResourceId = `asset:${notification.assetId}`;
|
||||
setResourcePreviewVersionByResourceId((current) => {
|
||||
if (current.get(committedResourceId) === notification.commitId) {
|
||||
return current;
|
||||
}
|
||||
const next = new Map(current);
|
||||
next.set(committedResourceId, notification.commitId);
|
||||
return next;
|
||||
});
|
||||
onManifestChange?.(projectPath, notification.manifest, {
|
||||
projectId: notification.projectId,
|
||||
revision: notification.projectRevision,
|
||||
@@ -3659,7 +3672,7 @@ export default function ProjectDevelopmentView({
|
||||
}
|
||||
pendingResourceFocusRef.current = {
|
||||
...focusIntent,
|
||||
resourceId: `asset:${notification.assetId}`,
|
||||
resourceId: committedResourceId,
|
||||
};
|
||||
setAssetCanvasRoute(null);
|
||||
setMode('resources');
|
||||
@@ -3832,6 +3845,13 @@ export default function ProjectDevelopmentView({
|
||||
setMode('run');
|
||||
}
|
||||
|
||||
const showRunUnavailableHint =
|
||||
!runAvailable &&
|
||||
!focusedResource &&
|
||||
!assetCanvasRoute &&
|
||||
!resourceEditorRoute &&
|
||||
!uiEditorRoute;
|
||||
|
||||
return (
|
||||
<section
|
||||
className="launcher-page launcher-project-development game-project-workbench"
|
||||
@@ -3876,7 +3896,7 @@ export default function ProjectDevelopmentView({
|
||||
role="tab"
|
||||
aria-selected={mode === 'run'}
|
||||
aria-describedby={
|
||||
!runAvailable ? 'run-unavailable-hint' : undefined
|
||||
showRunUnavailableHint ? 'run-unavailable-hint' : undefined
|
||||
}
|
||||
data-unavailable={!runAvailable || uiEditorRoute || undefined}
|
||||
className={mode === 'run' ? 'is-active' : ''}
|
||||
@@ -3968,7 +3988,7 @@ export default function ProjectDevelopmentView({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!runAvailable && !assetCanvasRoute && !resourceEditorRoute ? (
|
||||
{showRunUnavailableHint ? (
|
||||
<p
|
||||
id="run-unavailable-hint"
|
||||
className="game-run-unavailable"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { ProjectResourceCanvasLayoutMode } from '../../../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import type { ProjectResource } from './resourceProjectionModel';
|
||||
|
||||
export const PROJECT_RESOURCE_CARD_PREVIEW_CONCURRENCY = 3;
|
||||
@@ -174,17 +173,17 @@ export function projectResourceCardPreviewKind(
|
||||
export function projectResourceCardPreviewIdentity(input: {
|
||||
projectPath: string;
|
||||
projectId: string;
|
||||
mode: ProjectResourceCanvasLayoutMode;
|
||||
previewVersion?: string;
|
||||
resource: ProjectResource;
|
||||
}) {
|
||||
return JSON.stringify([
|
||||
input.projectPath,
|
||||
input.projectId,
|
||||
input.mode,
|
||||
input.resource.id,
|
||||
input.resource.category,
|
||||
input.resource.path,
|
||||
input.resource.mediaType,
|
||||
input.previewVersion ?? '',
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
+10
-7
@@ -199,12 +199,9 @@ export function useProjectResourceCardPreviews(input: {
|
||||
resources: ProjectResource[];
|
||||
canvasRef: React.RefObject<HTMLDivElement | null>;
|
||||
eagerPreviewLimit?: number;
|
||||
previewVersionByResourceId?: ReadonlyMap<string, string>;
|
||||
}) {
|
||||
const scopeKey = JSON.stringify([
|
||||
input.projectPath,
|
||||
input.projectId,
|
||||
input.mode,
|
||||
]);
|
||||
const scopeKey = JSON.stringify([input.projectPath, input.projectId]);
|
||||
const identityByResourceId = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
@@ -213,12 +210,18 @@ export function useProjectResourceCardPreviews(input: {
|
||||
projectResourceCardPreviewIdentity({
|
||||
projectPath: input.projectPath,
|
||||
projectId: input.projectId,
|
||||
mode: input.mode,
|
||||
previewVersion:
|
||||
input.previewVersionByResourceId?.get(resource.id) ?? '',
|
||||
resource,
|
||||
}),
|
||||
]),
|
||||
),
|
||||
[input.mode, input.projectId, input.projectPath, input.resources],
|
||||
[
|
||||
input.previewVersionByResourceId,
|
||||
input.projectId,
|
||||
input.projectPath,
|
||||
input.resources,
|
||||
],
|
||||
);
|
||||
const [previews, setPreviews] = useState<
|
||||
Map<string, ProjectResourceCardPreviewState>
|
||||
|
||||
@@ -1963,7 +1963,12 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
'resources.focused.document',
|
||||
);
|
||||
expect(screen.getByLabelText('搜索项目资源')).not.toBeNull();
|
||||
expect(screen.getByRole('button', { name: '新增资源' })).not.toBeNull();
|
||||
expect(screen.getByRole('button', { name: '生成视频' })).not.toBeNull();
|
||||
expect(screen.getByRole('button', { name: '生成音效' })).not.toBeNull();
|
||||
expect(
|
||||
screen.getByRole('button', { name: '生成背景音乐' }),
|
||||
).not.toBeNull();
|
||||
expect(screen.getByRole('button', { name: '新增 UI 设计' })).not.toBeNull();
|
||||
expect(screen.getByRole('button', { name: '按依赖' })).not.toBeNull();
|
||||
expect(
|
||||
screen
|
||||
@@ -4012,6 +4017,35 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
expect(projectDevelopmentSource).toMatch(
|
||||
/<div className="game-workbench-chat-wallet">\{walletEntry\}<\/div>/,
|
||||
);
|
||||
expect(projectDevelopmentSource).toMatch(
|
||||
/const showRunUnavailableHint\s*=\s*!runAvailable\s*&&\s*!focusedResource\s*&&\s*!assetCanvasRoute\s*&&\s*!resourceEditorRoute\s*&&\s*!uiEditorRoute/s,
|
||||
);
|
||||
expect(projectDevelopmentSource).toMatch(
|
||||
/aria-describedby=\{\s*showRunUnavailableHint\s*\?\s*'run-unavailable-hint'\s*:\s*undefined\s*\}/s,
|
||||
);
|
||||
expect(projectDevelopmentSource).toMatch(
|
||||
/\{showRunUnavailableHint\s*\?\s*\(\s*<p\s+id="run-unavailable-hint"/s,
|
||||
);
|
||||
});
|
||||
|
||||
it('invalidates only the committed asset preview after an in-place refine', () => {
|
||||
const projectDevelopmentSource = readFileSync(
|
||||
resolve(
|
||||
process.cwd(),
|
||||
'apps/ai-game-creator-shell/src/view/project-development/index.tsx',
|
||||
),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
expect(projectDevelopmentSource).toMatch(
|
||||
/previewVersionByResourceId:\s*resourcePreviewVersionByResourceId/,
|
||||
);
|
||||
expect(projectDevelopmentSource).toMatch(
|
||||
/const committedResourceId = `asset:\$\{notification\.assetId\}`;[\s\S]*?next\.set\(committedResourceId, notification\.commitId\)/,
|
||||
);
|
||||
expect(projectDevelopmentSource).toMatch(
|
||||
/setResourcePreviewVersionByResourceId\(new Map\(\)\)/,
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps resource sort tab keyboard focus inside the clipped segmented control', () => {
|
||||
|
||||
@@ -583,13 +583,13 @@ describe('useProjectResourceCardPreviews', () => {
|
||||
await waitFor(() => expect(previewReadCalls(invoke)).toHaveLength(7));
|
||||
});
|
||||
|
||||
it('rotates the native scope for a mode-only change and cancels the final scope on unmount', () => {
|
||||
it('preserves the preview identity, dimensions, and native scope for a mode-only change', async () => {
|
||||
const art = resource('mode-switch-art');
|
||||
const invoke = vi.fn((command: string) => {
|
||||
const invoke = vi.fn(async (command: string) => {
|
||||
if (command === 'cancel_local_project_resource_preview_scope') {
|
||||
return Promise.resolve();
|
||||
return undefined;
|
||||
}
|
||||
return new Promise(() => undefined);
|
||||
return preview(art.path);
|
||||
});
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
const canvasRef = { current: document.createElement('div') };
|
||||
@@ -601,35 +601,35 @@ describe('useProjectResourceCardPreviews', () => {
|
||||
mode,
|
||||
resources: [art],
|
||||
canvasRef,
|
||||
eagerPreviewLimit: 1,
|
||||
}),
|
||||
{ initialProps: 'dependency' as const },
|
||||
);
|
||||
|
||||
act(() =>
|
||||
result.current.requestPreview(
|
||||
art,
|
||||
result.current.identityByResourceId.get(art.id)!,
|
||||
'visible',
|
||||
),
|
||||
const firstIdentity = result.current.identityByResourceId.get(art.id)!;
|
||||
await waitFor(() =>
|
||||
expect(result.current.previews.get(firstIdentity)?.status).toBe('loaded'),
|
||||
);
|
||||
const firstScopeId = previewReadCalls(invoke)[0]?.[1]?.scopeId;
|
||||
expect(firstScopeId).toEqual(expect.any(String));
|
||||
expect(result.current.imageDimensionsByResourceId.get(art.id)).toEqual({
|
||||
pixelWidth: 640,
|
||||
pixelHeight: 360,
|
||||
});
|
||||
|
||||
rerender('type');
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
'cancel_local_project_resource_preview_scope',
|
||||
{ scopeId: firstScopeId },
|
||||
);
|
||||
act(() =>
|
||||
result.current.requestPreview(
|
||||
art,
|
||||
result.current.identityByResourceId.get(art.id)!,
|
||||
'visible',
|
||||
expect(result.current.identityByResourceId.get(art.id)).toBe(firstIdentity);
|
||||
expect(result.current.previews.get(firstIdentity)?.status).toBe('loaded');
|
||||
expect(result.current.imageDimensionsByResourceId.get(art.id)).toEqual({
|
||||
pixelWidth: 640,
|
||||
pixelHeight: 360,
|
||||
});
|
||||
expect(previewReadCalls(invoke)).toHaveLength(1);
|
||||
expect(
|
||||
invoke.mock.calls.filter(
|
||||
([command]) =>
|
||||
command === 'cancel_local_project_resource_preview_scope',
|
||||
),
|
||||
);
|
||||
const secondScopeId = previewReadCalls(invoke)[1]?.[1]?.scopeId;
|
||||
expect(secondScopeId).toEqual(expect.any(String));
|
||||
expect(secondScopeId).not.toBe(firstScopeId);
|
||||
).toHaveLength(0);
|
||||
|
||||
unmount();
|
||||
const cancelledScopeIds = invoke.mock.calls
|
||||
@@ -638,7 +638,57 @@ describe('useProjectResourceCardPreviews', () => {
|
||||
command === 'cancel_local_project_resource_preview_scope',
|
||||
)
|
||||
.map((call) => call[1]?.scopeId);
|
||||
expect(cancelledScopeIds).toEqual([firstScopeId, secondScopeId]);
|
||||
expect(cancelledScopeIds).toEqual([firstScopeId]);
|
||||
});
|
||||
|
||||
it('invalidates an in-place asset preview when its committed version changes', async () => {
|
||||
const art = resource('refined-art');
|
||||
const invoke = vi.fn(
|
||||
async (_command: string, args?: Record<string, unknown>) =>
|
||||
preview(String(args?.relativePath ?? art.path)),
|
||||
);
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
const canvasRef = { current: document.createElement('div') };
|
||||
const { result, rerender } = renderHook(
|
||||
(previewVersion: string) =>
|
||||
useProjectResourceCardPreviews({
|
||||
projectPath: '/tmp/preview-refine-version',
|
||||
projectId: 'preview-refine-version',
|
||||
mode: 'dependency',
|
||||
resources: [art],
|
||||
canvasRef,
|
||||
eagerPreviewLimit: 1,
|
||||
previewVersionByResourceId: new Map([[art.id, previewVersion]]),
|
||||
}),
|
||||
{ initialProps: 'commit-1' },
|
||||
);
|
||||
const firstIdentity = result.current.identityByResourceId.get(art.id)!;
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.previews.get(firstIdentity)?.status).toBe('loaded'),
|
||||
);
|
||||
expect(URL.createObjectURL).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender('commit-2');
|
||||
const secondIdentity = result.current.identityByResourceId.get(art.id)!;
|
||||
expect(secondIdentity).not.toBe(firstIdentity);
|
||||
await waitFor(() =>
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith(
|
||||
'blob:resource-preview-0',
|
||||
),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(result.current.previews.get(secondIdentity)?.status).toBe(
|
||||
'loaded',
|
||||
),
|
||||
);
|
||||
expect(previewReadCalls(invoke)).toHaveLength(2);
|
||||
expect(URL.createObjectURL).toHaveBeenCalledTimes(2);
|
||||
expect(result.current.previews.has(firstIdentity)).toBe(false);
|
||||
expect(result.current.imageDimensionsByResourceId.get(art.id)).toEqual({
|
||||
pixelWidth: 640,
|
||||
pixelHeight: 360,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects an A-B-A late result even when scope and identity match again', async () => {
|
||||
|
||||
Reference in New Issue
Block a user