Merge branch 'master' into fix/windows-acl
This commit is contained in:
@@ -419,6 +419,7 @@ type AppProps = {
|
||||
summaries: ProjectAgentRuntimeSummary[],
|
||||
) => void;
|
||||
onAgentResultsChange?: (results: ProjectAgentResultSummary[]) => void;
|
||||
onMakeGameFromApprovedGdd?: (projectPath: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export function App({
|
||||
@@ -437,6 +438,7 @@ export function App({
|
||||
onPreviewChange,
|
||||
onAgentRuntimeSummariesChange,
|
||||
onAgentResultsChange,
|
||||
onMakeGameFromApprovedGdd,
|
||||
}: AppProps = {}) {
|
||||
const { setTitle: setWindowTitle } = useWindowChrome();
|
||||
// 做方案入口独立成链:立项策划需要委派、澄清 pending 与 GDD 审批,这些只存在于
|
||||
@@ -10915,6 +10917,14 @@ export function App({
|
||||
planGddError={planGddError}
|
||||
onPlanGddRefresh={() => void hydratePlanGddState()}
|
||||
onPlanGddDecision={decidePlanGdd}
|
||||
onMakeGameFromApprovedGdd={
|
||||
onMakeGameFromApprovedGdd
|
||||
? () =>
|
||||
onMakeGameFromApprovedGdd(
|
||||
localProject?.projectPath ?? projectPath,
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
runtime={projectSupervisorRuntime}
|
||||
error={projectSupervisorRuntimeError}
|
||||
runtimeByAgentId={agentRuntimeById}
|
||||
@@ -11013,6 +11023,14 @@ export function App({
|
||||
planGddError={planGddError}
|
||||
onPlanGddRefresh={() => void hydratePlanGddState()}
|
||||
onPlanGddDecision={decidePlanGdd}
|
||||
onMakeGameFromApprovedGdd={
|
||||
onMakeGameFromApprovedGdd
|
||||
? () =>
|
||||
onMakeGameFromApprovedGdd(
|
||||
localProject?.projectPath ?? projectPath,
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
queueAgentRunControlFromPanel={queueAgentRunControlFromPanel}
|
||||
queueOrExecuteProjectIndex={queueOrExecuteProjectIndex}
|
||||
queuePendingCommand={queuePendingCommand}
|
||||
|
||||
@@ -77,6 +77,7 @@ export function WorkspaceLauncherShell({
|
||||
activeProjectAgentResults,
|
||||
setAgentResults: setActiveProjectAgentResults,
|
||||
resetLauncherHomeDraft,
|
||||
startGameFromApprovedGdd,
|
||||
createHomeDraftAutomatically,
|
||||
openProject,
|
||||
} = homeProject;
|
||||
@@ -351,6 +352,7 @@ export function WorkspaceLauncherShell({
|
||||
setActiveProjectAgentRuntimeSummaries
|
||||
}
|
||||
onAgentResultsChange={setActiveProjectAgentResults}
|
||||
onMakeGameFromApprovedGdd={startGameFromApprovedGdd}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -50,6 +50,7 @@ export type ProjectSupervisorComponentProps = {
|
||||
summaries: ProjectAgentRuntimeSummary[],
|
||||
) => void;
|
||||
onAgentResultsChange?: (results: ProjectAgentResultSummary[]) => void;
|
||||
onMakeGameFromApprovedGdd?: (projectPath: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export type WorkspaceLauncherShellProps = WorkspaceLauncherProps & {
|
||||
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
LauncherProjectContext,
|
||||
LocalGameProjectRevisionStatus,
|
||||
LocalProjectDirectoryStatus,
|
||||
LocalProjectFileResult,
|
||||
PendingNonEmptyProject,
|
||||
ProjectStartMode,
|
||||
TauriInvoke,
|
||||
@@ -48,6 +49,27 @@ type UseHomeProjectCreationOptions = {
|
||||
rememberRecentWorkspace: (projectPath: string) => void;
|
||||
};
|
||||
|
||||
const APPROVED_GDD_BUILD_PROMPT = [
|
||||
'请按照附件中的已批准 GDD 开始建造这款游戏。',
|
||||
'',
|
||||
'这份 GDD 已覆盖游戏定位与一句话概念、类型与美术方向、游戏支柱、核心循环、目标用户、平台与输入事实、MVP 系统、暂不纳入范围、创作者提示和原型验证项。',
|
||||
'',
|
||||
'请先阅读并理解附件中的 fast_gdd.md,以它作为本次建造的主要依据,优先实现其中 MVP 范围内的可运行游戏原型。',
|
||||
].join('\n');
|
||||
|
||||
function createTextAttachmentFile(content: string) {
|
||||
const file = new File([content], 'fast_gdd.md', {
|
||||
type: 'text/markdown',
|
||||
lastModified: Date.now(),
|
||||
});
|
||||
if (typeof file.arrayBuffer !== 'function') {
|
||||
Object.defineProperty(file, 'arrayBuffer', {
|
||||
value: async () => new TextEncoder().encode(content).buffer,
|
||||
});
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
export function useHomeProjectCreation({
|
||||
setStatus,
|
||||
setLauncherView,
|
||||
@@ -77,6 +99,7 @@ export function useHomeProjectCreation({
|
||||
const resetLauncherHomeDraft = useLauncherHomeDraftStore(
|
||||
(state) => state.reset,
|
||||
);
|
||||
const approvedGddStartInFlightRef = useRef(false);
|
||||
|
||||
function validateProjectPath(nextProjectPath: string) {
|
||||
const trimmedProjectPath = nextProjectPath.trim();
|
||||
@@ -91,6 +114,50 @@ export function useHomeProjectCreation({
|
||||
return trimmedProjectPath;
|
||||
}
|
||||
|
||||
async function startGameFromApprovedGdd(nextProjectPath: string) {
|
||||
if (approvedGddStartInFlightRef.current) {
|
||||
return;
|
||||
}
|
||||
approvedGddStartInFlightRef.current = true;
|
||||
const invoke = resolveTauriInvoke();
|
||||
try {
|
||||
if (!invoke) {
|
||||
throw new Error('需要在陶泥儿客户端内运行');
|
||||
}
|
||||
const projectPath = nextProjectPath.trim();
|
||||
if (!projectPath) {
|
||||
throw new Error('当前项目路径无效');
|
||||
}
|
||||
|
||||
setStatus('正在读取已批准 GDD');
|
||||
const result = await invoke<LocalProjectFileResult>(
|
||||
'read_local_project_file',
|
||||
{
|
||||
projectPath,
|
||||
relativePath: 'game/fast_gdd.md',
|
||||
commandId: 'file.read',
|
||||
},
|
||||
);
|
||||
const file = createTextAttachmentFile(result.content);
|
||||
|
||||
await createHomeDraftAutomatically(
|
||||
{
|
||||
creationType: 'game',
|
||||
prompt: APPROVED_GDD_BUILD_PROMPT,
|
||||
attachments: [
|
||||
{
|
||||
id: `approved-gdd-${Date.now().toString(36)}`,
|
||||
file,
|
||||
},
|
||||
],
|
||||
},
|
||||
'direct-build',
|
||||
);
|
||||
} finally {
|
||||
approvedGddStartInFlightRef.current = false;
|
||||
}
|
||||
}
|
||||
|
||||
function enterProjectDevelopment(context: LauncherProjectContext) {
|
||||
setCurrentProjectContext(context);
|
||||
setActiveProjectPreview(context.manifest.preview ?? null);
|
||||
@@ -600,6 +667,7 @@ export function useHomeProjectCreation({
|
||||
projectBusy: projectAction !== null,
|
||||
pendingNonEmptyProject,
|
||||
resetLauncherHomeDraft,
|
||||
startGameFromApprovedGdd,
|
||||
createHomeDraft,
|
||||
createHomeDraftAutomatically,
|
||||
openProject,
|
||||
|
||||
@@ -42,6 +42,7 @@ type GddApprovalCardProps = {
|
||||
action: PlanGddDecisionAction,
|
||||
comment: string | null,
|
||||
) => Promise<void>;
|
||||
onMakeGame?: () => Promise<void>;
|
||||
};
|
||||
|
||||
const stateLabels: Record<PlanGddStateViewV1['state'], string> = {
|
||||
@@ -82,6 +83,7 @@ export function PlanGddSurface({
|
||||
error,
|
||||
onRefresh,
|
||||
onDecision,
|
||||
onMakeGame,
|
||||
}: GddApprovalCardProps & { active?: boolean; projectPath: string }) {
|
||||
const showProgress = stageProgressVisible(state, active);
|
||||
const showCard = approvalCardVisible(state);
|
||||
@@ -98,6 +100,7 @@ export function PlanGddSurface({
|
||||
state={state}
|
||||
active={active}
|
||||
projectPath={projectPath}
|
||||
onMakeGame={onMakeGame}
|
||||
/>
|
||||
) : null}
|
||||
{showCard ? (
|
||||
@@ -118,14 +121,18 @@ export function PlanGddStageProgress({
|
||||
state,
|
||||
active = false,
|
||||
projectPath = '',
|
||||
onMakeGame,
|
||||
}: {
|
||||
state: PlanGddStateViewV1 | null;
|
||||
active?: boolean;
|
||||
projectPath?: string;
|
||||
onMakeGame?: () => Promise<void>;
|
||||
}) {
|
||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||
const [openError, setOpenError] = useState('');
|
||||
const [opening, setOpening] = useState(false);
|
||||
const [makingGame, setMakingGame] = useState(false);
|
||||
const [makeGameError, setMakeGameError] = useState('');
|
||||
if (!state || !stageProgressVisible(state, active)) {
|
||||
return null;
|
||||
}
|
||||
@@ -198,6 +205,25 @@ export function PlanGddStageProgress({
|
||||
>
|
||||
{opening ? '正在打开' : '打开文件'}
|
||||
</button>
|
||||
{onMakeGame ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={opening || makingGame || !projectPath.trim()}
|
||||
onClick={() => {
|
||||
setMakeGameError('');
|
||||
setMakingGame(true);
|
||||
void onMakeGame()
|
||||
.catch((error: unknown) =>
|
||||
setMakeGameError(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
),
|
||||
)
|
||||
.finally(() => setMakingGame(false));
|
||||
}}
|
||||
>
|
||||
{makingGame ? '正在启动' : '做成游戏'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{openError ? (
|
||||
<small
|
||||
@@ -207,6 +233,14 @@ export function PlanGddStageProgress({
|
||||
{openError}
|
||||
</small>
|
||||
) : null}
|
||||
{makeGameError ? (
|
||||
<small
|
||||
className="plan-gdd-stage-progress__delivery-error"
|
||||
role="alert"
|
||||
>
|
||||
{makeGameError}
|
||||
</small>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{detailsOpen && deliveredGdd ? (
|
||||
|
||||
@@ -68,6 +68,7 @@ type ProjectSupervisorViewProps = RuntimePanelProps & {
|
||||
action: PlanGddDecisionAction,
|
||||
comment: string | null,
|
||||
) => Promise<void>;
|
||||
onMakeGameFromApprovedGdd?: () => Promise<void>;
|
||||
};
|
||||
|
||||
export function ProjectSupervisorView({
|
||||
@@ -99,6 +100,7 @@ export function ProjectSupervisorView({
|
||||
planGddError,
|
||||
onPlanGddRefresh,
|
||||
onPlanGddDecision,
|
||||
onMakeGameFromApprovedGdd,
|
||||
...runtimePanelProps
|
||||
}: ProjectSupervisorViewProps) {
|
||||
const submitLabel = needsUserInput
|
||||
@@ -121,6 +123,7 @@ export function ProjectSupervisorView({
|
||||
error={planGddError}
|
||||
onRefresh={onPlanGddRefresh}
|
||||
onDecision={onPlanGddDecision}
|
||||
onMakeGame={onMakeGameFromApprovedGdd}
|
||||
/>
|
||||
<div
|
||||
ref={messagesRef}
|
||||
|
||||
@@ -189,6 +189,7 @@ type ProjectWorkspaceChatPaneProps = {
|
||||
action: PlanGddDecisionAction,
|
||||
comment: string | null,
|
||||
) => Promise<void>;
|
||||
onMakeGameFromApprovedGdd?: () => Promise<void>;
|
||||
queueAgentRunControlFromPanel: (action: 'kill' | 'retry' | 'resume') => void;
|
||||
queueOrExecuteProjectIndex: () => Promise<void>;
|
||||
queuePendingCommand: (command: PendingCommand) => void;
|
||||
@@ -278,6 +279,7 @@ export function ProjectWorkspaceChatPane({
|
||||
planGddError,
|
||||
onPlanGddRefresh,
|
||||
onPlanGddDecision,
|
||||
onMakeGameFromApprovedGdd,
|
||||
queueAgentRunControlFromPanel,
|
||||
queueOrExecuteProjectIndex,
|
||||
queuePendingCommand,
|
||||
@@ -376,6 +378,7 @@ export function ProjectWorkspaceChatPane({
|
||||
error={planGddError}
|
||||
onRefresh={onPlanGddRefresh}
|
||||
onDecision={onPlanGddDecision}
|
||||
onMakeGame={onMakeGameFromApprovedGdd}
|
||||
/>
|
||||
<div className="chat-quick-actions" aria-label="预览快捷操作">
|
||||
<button
|
||||
|
||||
@@ -5458,6 +5458,31 @@ iframe.preview-frame {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.game-resource-card-type-badge {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: 2;
|
||||
display: inline-flex;
|
||||
max-width: calc(100% - 16px);
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
padding: 4px 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgb(255 255 255 / 72%);
|
||||
border-radius: 999px;
|
||||
background: rgb(75 48 38 / 84%);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
letter-spacing: 0.02em;
|
||||
pointer-events: none;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 8px 18px rgb(96 62 47 / 20%);
|
||||
}
|
||||
|
||||
.game-resource-card-open {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
|
||||
@@ -82,10 +82,10 @@ import {
|
||||
normalizeInfiniteResourceCanvasViewport,
|
||||
RESOURCE_CANVAS_DRAG_THRESHOLD,
|
||||
RESOURCE_CANVAS_FIT_PADDING,
|
||||
RESOURCE_CANVAS_INITIAL_FIT_MAX_SCALE,
|
||||
RESOURCE_CANVAS_SECTION_MIN_HEIGHT,
|
||||
RESOURCE_CANVAS_SECTION_MIN_WIDTH,
|
||||
RESOURCE_CANVAS_SECTION_ORDER,
|
||||
RESOURCE_CANVAS_VISIBLE_SECTION_ORDER,
|
||||
type ResourceCanvasCardSize,
|
||||
resourceCanvasCardSize,
|
||||
resourceCanvasContentBounds,
|
||||
@@ -123,6 +123,7 @@ import {
|
||||
type ProjectResource,
|
||||
type ProjectResourceCategory,
|
||||
projectResourcesFromReadModels,
|
||||
projectResourceTypeLabel,
|
||||
} from './resourceProjectionModel';
|
||||
import {
|
||||
clampProjectResourceSectionZoom,
|
||||
@@ -393,6 +394,7 @@ export type ProjectDevelopmentViewProps = {
|
||||
};
|
||||
|
||||
const categoryOrder = RESOURCE_CANVAS_SECTION_ORDER;
|
||||
const visibleCategoryOrder = RESOURCE_CANVAS_VISIBLE_SECTION_ORDER;
|
||||
|
||||
const categoryLabels: Record<ResourceCategory, string> = {
|
||||
code: '游戏代码',
|
||||
@@ -592,6 +594,7 @@ const ResourceCard = memo(function ResourceCard({
|
||||
const [decodedIdentity, setDecodedIdentity] = useState<string | null>(null);
|
||||
const Icon = categoryIcons[resource.category];
|
||||
const kind = projectResourceCardPreviewKind(resource);
|
||||
const resourceTypeLabel = projectResourceTypeLabel(resource);
|
||||
const isMedia = kind === 'video' || kind === 'audio';
|
||||
const mediaActive = activeMediaIdentity === previewIdentity;
|
||||
const sourceUrl =
|
||||
@@ -797,6 +800,13 @@ const ResourceCard = memo(function ResourceCard({
|
||||
<span className="game-resource-card-visual" aria-hidden="true">
|
||||
{visual}
|
||||
</span>
|
||||
<span
|
||||
className="game-resource-card-type-badge"
|
||||
data-resource-type={resourceTypeLabel}
|
||||
title={resourceTypeLabel}
|
||||
>
|
||||
{resourceTypeLabel}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="game-resource-card-open"
|
||||
@@ -1243,6 +1253,10 @@ export default function ProjectDevelopmentView({
|
||||
}),
|
||||
[manifestTaskById, projectedResources, resourceGraph],
|
||||
);
|
||||
const canvasResources = useMemo(
|
||||
() => resources.filter((resource) => resource.category !== 'code'),
|
||||
[resources],
|
||||
);
|
||||
// 做方案在拿到第一个产物之前,左边的资源画布是空的(文档 0 项、项目版本 0 项),而澄清
|
||||
// 问答、决定卡和 GDD 审批全挤在右边那一列里。这段时间先把画布收掉让对话铺满,一旦有第
|
||||
// 一个已登记资源就自动恢复双栏。用 CSS 收而不是不渲染:画布里的页签、缩放和选中状态都
|
||||
@@ -1288,7 +1302,7 @@ export default function ProjectDevelopmentView({
|
||||
projectPath,
|
||||
projectId: manifest.projectId,
|
||||
mode: sortMode,
|
||||
resources,
|
||||
resources: canvasResources,
|
||||
canvasRef: resourceCanvasRef,
|
||||
eagerPreviewLimit: 12,
|
||||
previewVersionByResourceId: resourcePreviewVersionByResourceId,
|
||||
@@ -1382,7 +1396,7 @@ export default function ProjectDevelopmentView({
|
||||
const normalizedSearch = searchText.trim().toLowerCase();
|
||||
const visibleResources = useMemo(
|
||||
() =>
|
||||
resources.filter((resource) =>
|
||||
canvasResources.filter((resource) =>
|
||||
normalizedSearch
|
||||
? [
|
||||
resource.label,
|
||||
@@ -1392,7 +1406,7 @@ export default function ProjectDevelopmentView({
|
||||
].some((value) => value.toLowerCase().includes(normalizedSearch))
|
||||
: true,
|
||||
),
|
||||
[normalizedSearch, resources],
|
||||
[canvasResources, normalizedSearch],
|
||||
);
|
||||
const visibleResourceIds = useMemo(
|
||||
() => new Set(visibleResources.map((resource) => resource.id)),
|
||||
@@ -1401,7 +1415,7 @@ export default function ProjectDevelopmentView({
|
||||
const visibleResourcesByCategory = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
categoryOrder.map((category) => [
|
||||
visibleCategoryOrder.map((category) => [
|
||||
category,
|
||||
visibleResources.filter((resource) => resource.category === category),
|
||||
]),
|
||||
@@ -1411,17 +1425,17 @@ export default function ProjectDevelopmentView({
|
||||
const projectResourcesByCategory = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
categoryOrder.map((category) => [
|
||||
visibleCategoryOrder.map((category) => [
|
||||
category,
|
||||
resources.filter((resource) => resource.category === category),
|
||||
canvasResources.filter((resource) => resource.category === category),
|
||||
]),
|
||||
),
|
||||
[resources],
|
||||
[canvasResources],
|
||||
);
|
||||
const resourceIdsByCategory = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
categoryOrder.map((category) => [
|
||||
visibleCategoryOrder.map((category) => [
|
||||
category,
|
||||
new Set(
|
||||
(projectResourcesByCategory.get(category) ?? []).map(
|
||||
@@ -1437,8 +1451,8 @@ export default function ProjectDevelopmentView({
|
||||
[manifest.projectId, projectPath],
|
||||
);
|
||||
const resourcePageCategories = useMemo(
|
||||
() => (resources.length > 0 ? categoryOrder : []),
|
||||
[resources.length],
|
||||
() => (canvasResources.length > 0 ? visibleCategoryOrder : []),
|
||||
[canvasResources.length],
|
||||
);
|
||||
const activePageCategory =
|
||||
resourcePageCategories.find(
|
||||
@@ -1468,16 +1482,18 @@ export default function ProjectDevelopmentView({
|
||||
return;
|
||||
}
|
||||
|
||||
const categoriesWithNewResources = categoryOrder.filter((category) => {
|
||||
if (category === viewedResourceCategory) {
|
||||
return false;
|
||||
}
|
||||
const previousIds =
|
||||
previousSnapshot.resourceIdsByCategory.get(category) ?? new Set();
|
||||
return Array.from(resourceIdsByCategory.get(category) ?? []).some(
|
||||
(resourceId) => !previousIds.has(resourceId),
|
||||
);
|
||||
});
|
||||
const categoriesWithNewResources = visibleCategoryOrder.filter(
|
||||
(category) => {
|
||||
if (category === viewedResourceCategory) {
|
||||
return false;
|
||||
}
|
||||
const previousIds =
|
||||
previousSnapshot.resourceIdsByCategory.get(category) ?? new Set();
|
||||
return Array.from(resourceIdsByCategory.get(category) ?? []).some(
|
||||
(resourceId) => !previousIds.has(resourceId),
|
||||
);
|
||||
},
|
||||
);
|
||||
resourceCategorySnapshotRef.current = {
|
||||
scopeKey: resourceCategoryScopeKey,
|
||||
resourceIdsByCategory,
|
||||
@@ -1683,9 +1699,11 @@ export default function ProjectDevelopmentView({
|
||||
}
|
||||
}, [activePageCategory, activeResourceCanvasViewport]);
|
||||
const selectedResource =
|
||||
resources.find((resource) => resource.id === selectedResourceId) ?? null;
|
||||
canvasResources.find((resource) => resource.id === selectedResourceId) ??
|
||||
null;
|
||||
const focusedResource =
|
||||
resources.find((resource) => resource.id === focusedResourceId) ?? null;
|
||||
canvasResources.find((resource) => resource.id === focusedResourceId) ??
|
||||
null;
|
||||
const focusedResourceDependencyDetails = useMemo(() => {
|
||||
if (!focusedResource) {
|
||||
return {
|
||||
@@ -2245,10 +2263,6 @@ export default function ProjectDevelopmentView({
|
||||
bounds: resourceCanvasFitBounds,
|
||||
canvasSize: { width: measuredWidth, height: measuredHeight },
|
||||
padding: RESOURCE_CANVAS_FIT_PADDING,
|
||||
maxScale:
|
||||
activePageCategory === 'art'
|
||||
? RESOURCE_CANVAS_INITIAL_FIT_MAX_SCALE
|
||||
: undefined,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
@@ -2853,10 +2867,6 @@ export default function ProjectDevelopmentView({
|
||||
bounds: resourceCanvasFitBounds,
|
||||
canvasSize,
|
||||
padding: RESOURCE_CANVAS_FIT_PADDING,
|
||||
maxScale:
|
||||
activePageCategory === 'art'
|
||||
? RESOURCE_CANVAS_INITIAL_FIT_MAX_SCALE
|
||||
: undefined,
|
||||
}),
|
||||
);
|
||||
}, [activePageCategory, resourceCanvasFitBounds, setResourceCanvasViewport]);
|
||||
@@ -5035,7 +5045,7 @@ export default function ProjectDevelopmentView({
|
||||
</div>
|
||||
) : (
|
||||
<div className="game-resource-section-stack">
|
||||
{categoryOrder.map((category) => {
|
||||
{visibleCategoryOrder.map((category) => {
|
||||
const Icon = categoryIcons[category];
|
||||
const sectionHeight =
|
||||
resourceSectionHeights.sectionStates.get(category);
|
||||
|
||||
+9
-1
@@ -44,6 +44,14 @@ export const RESOURCE_CANVAS_FIT_PADDING = 16;
|
||||
export const RESOURCE_CANVAS_SECTION_ORDER: readonly ProjectResourceCanvasSection[] =
|
||||
['document', 'art', 'audio', 'code', 'version'];
|
||||
|
||||
/**
|
||||
* Ordinary resource-canvas navigation intentionally omits game code. The
|
||||
* complete five-section order above remains the internal layout/sidecar
|
||||
* contract so existing code-resource coordinates are not rewritten.
|
||||
*/
|
||||
export const RESOURCE_CANVAS_VISIBLE_SECTION_ORDER: readonly ProjectResourceCanvasSection[] =
|
||||
['document', 'art', 'audio', 'version'];
|
||||
|
||||
export type ResourceCanvasCardSize = {
|
||||
width: number;
|
||||
height: number;
|
||||
@@ -1345,7 +1353,7 @@ export function fitResourceCanvasViewportToContent({
|
||||
bounds,
|
||||
canvasSize,
|
||||
padding = RESOURCE_CANVAS_FIT_PADDING,
|
||||
maxScale = MAX_SCALE,
|
||||
maxScale = RESOURCE_CANVAS_INITIAL_FIT_MAX_SCALE,
|
||||
}: {
|
||||
bounds: { x: number; y: number; width: number; height: number };
|
||||
canvasSize: { width: number; height: number };
|
||||
|
||||
@@ -55,6 +55,18 @@ export type ProjectResource = {
|
||||
imageSequenceDurationMs?: number | null;
|
||||
};
|
||||
|
||||
export type ProjectResourceTypeLabel =
|
||||
| '图片'
|
||||
| 'SVG'
|
||||
| '视频'
|
||||
| '音频'
|
||||
| '文档'
|
||||
| '任务产物'
|
||||
| 'Agent 回执'
|
||||
| '项目版本'
|
||||
| '游戏代码'
|
||||
| '未知';
|
||||
|
||||
const documentExtension =
|
||||
/\.(md|markdown|mdx|txt|json|ya?ml|toml|csv|ini|conf|xml)$/iu;
|
||||
const gameCodeExtension =
|
||||
@@ -116,6 +128,55 @@ export function classifyProjectedResource(input: {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an already projected resource into short user-facing type text.
|
||||
* This is deliberately display-only: it derives from existing projection
|
||||
* fields and does not add a backend/read-model attribute.
|
||||
*/
|
||||
export function projectResourceTypeLabel(
|
||||
resource: Pick<
|
||||
ProjectResource,
|
||||
'category' | 'subtype' | 'path' | 'mediaType'
|
||||
>,
|
||||
): ProjectResourceTypeLabel {
|
||||
const subtype = resource.subtype.trim().toLowerCase();
|
||||
const path = resource.path.trim().toLowerCase();
|
||||
const mediaType = resource.mediaType.trim().toLowerCase();
|
||||
|
||||
if (resource.category === 'version' || subtype === 'project-version') {
|
||||
return '项目版本';
|
||||
}
|
||||
if (subtype === 'agent-result') {
|
||||
return 'Agent 回执';
|
||||
}
|
||||
if (resource.category === 'code') {
|
||||
return '游戏代码';
|
||||
}
|
||||
if (mediaType === 'image/svg+xml' || /\.svg$/iu.test(path)) {
|
||||
return 'SVG';
|
||||
}
|
||||
if (
|
||||
resource.category === 'audio' ||
|
||||
mediaType.startsWith('audio/') ||
|
||||
audioExtension.test(path)
|
||||
) {
|
||||
return '音频';
|
||||
}
|
||||
if (mediaType.startsWith('video/') || /\.(mp4|webm|mov)$/iu.test(path)) {
|
||||
return '视频';
|
||||
}
|
||||
if (resource.category === 'document' || mediaType.startsWith('text/')) {
|
||||
return '文档';
|
||||
}
|
||||
if (resource.category === 'art' || mediaType.startsWith('image/')) {
|
||||
return '图片';
|
||||
}
|
||||
if (subtype === 'task-artifact') {
|
||||
return '任务产物';
|
||||
}
|
||||
return '未知';
|
||||
}
|
||||
|
||||
function resourcePriority(resource: ProjectResource) {
|
||||
if (resource.manifestAssetId) {
|
||||
return 4;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { PROJECT_SUPERVISOR_PLAN_SOURCE } from '../../src/app/constants';
|
||||
import type { ProjectSupervisorComponentProps } from '../../src/features/app-shell/model';
|
||||
import { useHomeProjectCreation } from '../../src/features/app-shell/useHomeProjectCreation';
|
||||
import { WorkspaceLauncherShell } from '../../src/features/app-shell/WorkspaceLauncher';
|
||||
import type { LauncherView } from '../../src/view/layout';
|
||||
import {
|
||||
act,
|
||||
agentRuntimeUserInputRequest,
|
||||
@@ -27,6 +29,38 @@ import {
|
||||
within,
|
||||
} from './harness';
|
||||
|
||||
function ApprovedGddStartHarness() {
|
||||
const [, setLauncherView] = React.useState<LauncherView>(
|
||||
'project-development',
|
||||
);
|
||||
const [, setStatus] = React.useState('');
|
||||
const [, setAgentChatProjectPath] = React.useState('');
|
||||
const controller = useHomeProjectCreation({
|
||||
setStatus,
|
||||
setLauncherView,
|
||||
setAgentChatProjectPath,
|
||||
rememberRecentWorkspace: () => undefined,
|
||||
});
|
||||
|
||||
if (controller.currentProjectContext) {
|
||||
return React.createElement(
|
||||
'p',
|
||||
{ 'aria-label': '已进入自动游戏项目' },
|
||||
controller.currentProjectContext.initialPrompt,
|
||||
);
|
||||
}
|
||||
|
||||
return React.createElement(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
onClick: () =>
|
||||
void controller.startGameFromApprovedGdd('/tmp/planning-project'),
|
||||
},
|
||||
'直接用已批准 GDD 开始建造',
|
||||
);
|
||||
}
|
||||
|
||||
export function registerClientHomeTests() {
|
||||
it('anchors the empty home input placeholder to the editor while the page scrolls', () => {
|
||||
renderLauncherAt('/?launcher');
|
||||
@@ -1502,6 +1536,73 @@ export function registerHomeProjectCreationTests() {
|
||||
);
|
||||
});
|
||||
|
||||
it('starts an automatic game project from the approved GDD', async () => {
|
||||
const automaticProjectPath = '/tmp/approved-gdd-game';
|
||||
const manifest = createGameCreationAppManifest(
|
||||
'approved-gdd-game',
|
||||
'已批准 GDD 游戏',
|
||||
);
|
||||
const gddContent = '# Fast GDD\n\n批准后的方案内容';
|
||||
const invoke = vi.fn(async (command: string) => {
|
||||
if (command === 'read_local_project_file') {
|
||||
return {
|
||||
path: 'game/fast_gdd.md',
|
||||
absolutePath: '/tmp/planning-project/game/fast_gdd.md',
|
||||
content: gddContent,
|
||||
};
|
||||
}
|
||||
if (command === 'create_automatic_local_game_project') {
|
||||
return {
|
||||
projectPath: automaticProjectPath,
|
||||
manifestPath: `${automaticProjectPath}/.agent/manifest.json`,
|
||||
manifest,
|
||||
};
|
||||
}
|
||||
if (command === 'upload_local_asset') {
|
||||
return {
|
||||
id: 'approved-gdd-asset',
|
||||
localPath: 'assets/uploads/fast_gdd.md',
|
||||
absolutePath: `${automaticProjectPath}/assets/uploads/fast_gdd.md`,
|
||||
manifestPath: `${automaticProjectPath}/.agent/manifest.json`,
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected invoke ${command}`);
|
||||
});
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
render(React.createElement(ApprovedGddStartHarness));
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '直接用已批准 GDD 开始建造' }),
|
||||
);
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '直接用已批准 GDD 开始建造' }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
invoke.mock.calls.filter(
|
||||
([command]) => command === 'create_automatic_local_game_project',
|
||||
),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
expect(screen.getByLabelText('已进入自动游戏项目').textContent).toContain(
|
||||
'请按照附件中的已批准 GDD 开始建造这款游戏。',
|
||||
);
|
||||
expect(invoke).toHaveBeenCalledWith('read_local_project_file', {
|
||||
projectPath: '/tmp/planning-project',
|
||||
relativePath: 'game/fast_gdd.md',
|
||||
commandId: 'file.read',
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(invoke).toHaveBeenCalledWith('upload_local_asset', {
|
||||
projectPath: automaticProjectPath,
|
||||
fileName: 'fast_gdd.md',
|
||||
mediaType: 'text/markdown',
|
||||
bytes: Array.from(new TextEncoder().encode(gddContent)),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the home composer out of chat mode while automatic project creation is pending', async () => {
|
||||
let rejectAutomaticProject: ((error: Error) => void) | null = null;
|
||||
const automaticProject = new Promise<never>((_resolve, reject) => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import { PlanGddStageProgress } from '../../src/features/project-workspace/GddApprovalCard';
|
||||
import {
|
||||
agentRuntimeUserInputRequest,
|
||||
createPlanGddStateView,
|
||||
@@ -9,8 +10,11 @@ import {
|
||||
fireEvent,
|
||||
it,
|
||||
openMainProject,
|
||||
React,
|
||||
render,
|
||||
renderAppAt,
|
||||
screen,
|
||||
vi,
|
||||
waitFor,
|
||||
within,
|
||||
} from './harness';
|
||||
@@ -382,6 +386,24 @@ export function registerPlanGddApprovalTests() {
|
||||
expect(args).toEqual({ projectPath: harness.projectPath });
|
||||
});
|
||||
|
||||
it('offers to make an approved GDD into a game reference on the home entry', async () => {
|
||||
const onMakeGame = vi.fn(async () => undefined);
|
||||
render(
|
||||
React.createElement(PlanGddStageProgress, {
|
||||
state: approvedPlanGddState(),
|
||||
active: true,
|
||||
projectPath: '/tmp/approved-gdd-project',
|
||||
onMakeGame,
|
||||
}),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '做成游戏' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onMakeGame).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the delivery row hidden while the approval projection is still recovering', async () => {
|
||||
// 恢复态下权威投影还没收敛,磁盘上那份 Markdown 未必是用户批的那版。此时给出口
|
||||
// 等于让用户读一份可能已经失效的交付物。
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
RESOURCE_CANVAS_CARD_WIDTH,
|
||||
RESOURCE_CANVAS_COLUMN_GAP,
|
||||
RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP,
|
||||
RESOURCE_CANVAS_INITIAL_FIT_MAX_SCALE,
|
||||
} from '../../src/view/project-development/resourceCanvasLayoutModel';
|
||||
import { normalizeProjectResourceGraph } from '../../src/view/project-development/resourceDependencyGraphModel';
|
||||
import { ResourceDependencyOverlay } from '../../src/view/project-development/ResourceDependencyOverlay';
|
||||
@@ -429,6 +430,13 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
localPath: 'assets/section.mp3',
|
||||
source: { kind: 'generated', taskId: 'audio-asset-plan' },
|
||||
},
|
||||
{
|
||||
id: 'section-code',
|
||||
kind: 'game-code',
|
||||
mediaType: 'text/javascript',
|
||||
localPath: 'game/section.js',
|
||||
source: { kind: 'generated', taskId: 'code-prototype' },
|
||||
},
|
||||
];
|
||||
manifest.versions = [
|
||||
{
|
||||
@@ -448,9 +456,13 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
);
|
||||
addSectionResources(manifest);
|
||||
let layoutRevision = 0;
|
||||
let graphResourceIds: string[] = [];
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === 'read_local_project_resource_graph') {
|
||||
graphResourceIds = (
|
||||
(args?.resources as Array<{ resourceId: string }> | undefined) ?? []
|
||||
).map(({ resourceId }) => resourceId);
|
||||
return resourceGraphForInputs(args);
|
||||
}
|
||||
if (command === 'read_local_project_resource_canvas_layout') {
|
||||
@@ -529,7 +541,12 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
Array.from(outline.querySelectorAll('strong')).map(
|
||||
(item) => item.textContent,
|
||||
),
|
||||
).toEqual(['设计文档', '美术资源', '音乐音效', '游戏代码', '项目版本']);
|
||||
).toEqual(['设计文档', '美术资源', '音乐音效', '项目版本']);
|
||||
expect(graphResourceIds).toContain('asset:section-code');
|
||||
expect(screen.queryByRole('button', { name: /section\.js/ })).toBeNull();
|
||||
expect(
|
||||
screen.queryByRole('region', { name: '游戏代码资源画布' }),
|
||||
).toBeNull();
|
||||
expect(outline.querySelectorAll('small')).toHaveLength(0);
|
||||
expect(
|
||||
screen.getByRole('region', { name: '设计文档资源画布' }),
|
||||
@@ -541,11 +558,12 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
screen.getByRole('button', { name: /下一页\s*美术资源/ }),
|
||||
).not.toBeNull();
|
||||
|
||||
fireEvent.click(within(outline).getByRole('button', { name: /游戏代码/ }));
|
||||
expect(
|
||||
screen.getByRole('region', { name: '游戏代码资源画布' }),
|
||||
).not.toBeNull();
|
||||
expect(screen.getByText('没有匹配资源')).not.toBeNull();
|
||||
within(outline).queryByRole('button', { name: /游戏代码/ }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
screen.queryByRole('region', { name: '游戏代码资源画布' }),
|
||||
).toBeNull();
|
||||
fireEvent.click(within(outline).getByRole('button', { name: /文档/ }));
|
||||
|
||||
const dispatchPageWheel = (target: Element, deltaY = 160) => {
|
||||
@@ -606,10 +624,12 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
.map(Number);
|
||||
const fittedViewport = readViewport();
|
||||
const fittedBounds = readFitBounds();
|
||||
expect(
|
||||
Math.abs(fittedBounds[2]! * fittedViewport[2]! - 468) < 1 ||
|
||||
Math.abs(fittedBounds[3]! * fittedViewport[2]! - 268) < 1,
|
||||
).toBe(true);
|
||||
const expectedDocumentFitScale = Math.min(
|
||||
RESOURCE_CANVAS_INITIAL_FIT_MAX_SCALE,
|
||||
468 / fittedBounds[2]!,
|
||||
268 / fittedBounds[3]!,
|
||||
);
|
||||
expect(fittedViewport[2]).toBeCloseTo(expectedDocumentFitScale, 8);
|
||||
|
||||
const viewportBeforeZoom = fittedViewport;
|
||||
const zoomWheel = new WheelEvent('wheel', {
|
||||
@@ -626,6 +646,15 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
});
|
||||
expect(zoomWheelResult).toBe(false);
|
||||
expect(readViewport()[2]).toBeGreaterThan(viewportBeforeZoom[2]!);
|
||||
const documentViewportAfterZoom = readViewport();
|
||||
fireEvent.click(within(outline).getByRole('button', { name: /美术资源/ }));
|
||||
expect(
|
||||
screen.getByRole('region', { name: '美术资源资源画布' }),
|
||||
).not.toBeNull();
|
||||
fireEvent.click(within(outline).getByRole('button', { name: /设计文档/ }));
|
||||
await waitFor(() =>
|
||||
expect(readViewport()).toEqual(documentViewportAfterZoom),
|
||||
);
|
||||
expect(
|
||||
screen.getByRole('region', { name: '设计文档资源画布' }),
|
||||
).not.toBeNull();
|
||||
@@ -1051,10 +1080,16 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
}),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByLabelText('资源栏目大纲')).toBeNull();
|
||||
expect(screen.getAllByText('暂无已登记资源')).toHaveLength(4);
|
||||
expect(
|
||||
within(screen.getByLabelText('资源栏目大纲')).queryByRole('button', {
|
||||
name: /有新资源/,
|
||||
}),
|
||||
screen
|
||||
.getByLabelText(/资源(?:依赖|类型)视图/)
|
||||
.classList.contains('game-resource-canvas--paged'),
|
||||
).toBe(false);
|
||||
expect(screen.queryByRole('button', { name: /game\.js/ })).toBeNull();
|
||||
expect(
|
||||
screen.queryByRole('region', { name: '游戏代码资源画布' }),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1224,14 +1259,8 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
.getByLabelText(/资源(?:依赖|类型)视图/)
|
||||
.classList.contains('game-resource-canvas--dependency'),
|
||||
).toBe(false);
|
||||
expect(screen.getAllByText('暂无已登记资源')).toHaveLength(5);
|
||||
for (const label of [
|
||||
'设计文档',
|
||||
'美术资源',
|
||||
'音乐音效',
|
||||
'游戏代码',
|
||||
'项目版本',
|
||||
]) {
|
||||
expect(screen.getAllByText('暂无已登记资源')).toHaveLength(4);
|
||||
for (const label of ['设计文档', '美术资源', '音乐音效', '项目版本']) {
|
||||
expect(screen.getByRole('region', { name: label })).not.toBeNull();
|
||||
}
|
||||
};
|
||||
@@ -1394,6 +1423,9 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
expect(heroCard?.textContent).not.toContain('hero.png');
|
||||
expect(heroCard?.textContent).not.toContain('assets/hero.png');
|
||||
expect(heroCard?.textContent).not.toContain('Agent 生成');
|
||||
expect(
|
||||
heroCard?.querySelector('[data-resource-type="图片"]')?.textContent,
|
||||
).toBe('图片');
|
||||
expect(
|
||||
heroCard?.querySelector('.game-resource-card-open button'),
|
||||
).toBeNull();
|
||||
@@ -1411,7 +1443,12 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
});
|
||||
showResourcePage('设计文档');
|
||||
act(() => observer.triggerVisible());
|
||||
await screen.findByText(/这是安全的卡片正文摘要。/);
|
||||
const documentSummary = await screen.findByText(/这是安全的卡片正文摘要。/);
|
||||
expect(
|
||||
documentSummary
|
||||
.closest('.game-resource-card')
|
||||
?.querySelector('[data-resource-type="文档"]'),
|
||||
).not.toBeNull();
|
||||
showResourcePage('美术资源');
|
||||
expect(
|
||||
invoke.mock.calls.some(
|
||||
@@ -1425,6 +1462,9 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
name: '播放 intro.mp4',
|
||||
});
|
||||
const videoCard = videoControl.closest('.game-resource-card');
|
||||
expect(
|
||||
videoCard?.querySelector('[data-resource-type="视频"]'),
|
||||
).not.toBeNull();
|
||||
const video = videoCard?.querySelector('video');
|
||||
expect(video).not.toBeNull();
|
||||
expect(video?.preload).toBe('auto');
|
||||
@@ -1475,7 +1515,13 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
expect(document.activeElement).toBe(stableVideoControl);
|
||||
|
||||
showResourcePage('音乐音效');
|
||||
fireEvent.click(screen.getByRole('button', { name: '播放 theme.mp3' }));
|
||||
const audioControl = screen.getByRole('button', { name: '播放 theme.mp3' });
|
||||
expect(
|
||||
audioControl
|
||||
.closest('.game-resource-card')
|
||||
?.querySelector('[data-resource-type="音频"]'),
|
||||
).not.toBeNull();
|
||||
fireEvent.click(audioControl);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
invoke.mock.calls.some(
|
||||
@@ -2800,6 +2846,82 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
});
|
||||
});
|
||||
|
||||
it('hides dependency edges whose code endpoint is not visible while preserving visible edges', async () => {
|
||||
const visibleEdgeId = 'asset-reference:["art-a","art-b"]';
|
||||
const hiddenEdgeId = 'asset-reference:["code","art-a"]';
|
||||
const graph = normalizeProjectResourceGraph({
|
||||
resourceIds: ['code', 'art-a', 'art-b'],
|
||||
referenceEdges: [
|
||||
{
|
||||
id: visibleEdgeId,
|
||||
kind: 'asset-reference',
|
||||
sourceResourceId: 'art-a',
|
||||
targetResourceId: 'art-b',
|
||||
cyclic: false,
|
||||
},
|
||||
{
|
||||
id: hiddenEdgeId,
|
||||
kind: 'asset-reference',
|
||||
sourceResourceId: 'code',
|
||||
targetResourceId: 'art-a',
|
||||
cyclic: false,
|
||||
},
|
||||
],
|
||||
taskFlows: [],
|
||||
connectionIndex: [],
|
||||
producerAssignments: [],
|
||||
dependencyDepths: [],
|
||||
unresolvedReferenceResourceIds: [],
|
||||
cyclicResourceIds: [],
|
||||
cyclicTaskIds: [],
|
||||
producerMappingTruncated: false,
|
||||
});
|
||||
const positions: ProjectResourceCanvasPosition[] = [
|
||||
{
|
||||
resourceId: 'code',
|
||||
section: 'code',
|
||||
x: 0,
|
||||
y: 0,
|
||||
manuallyPlaced: false,
|
||||
},
|
||||
{
|
||||
resourceId: 'art-a',
|
||||
section: 'art',
|
||||
x: 0,
|
||||
y: 0,
|
||||
manuallyPlaced: false,
|
||||
},
|
||||
{
|
||||
resourceId: 'art-b',
|
||||
section: 'art',
|
||||
x: 0,
|
||||
y: 144,
|
||||
manuallyPlaced: false,
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
React.createElement(ResourceDependencyOverlay, {
|
||||
graph,
|
||||
positions,
|
||||
section: 'art',
|
||||
visibleResourceIds: new Set(['art-a', 'art-b']),
|
||||
}),
|
||||
);
|
||||
|
||||
const overlay = await screen.findByTestId(
|
||||
'resource-dependency-overlay-art',
|
||||
);
|
||||
await waitFor(() => {
|
||||
const edgeById = (edgeId: string) =>
|
||||
Array.from(
|
||||
overlay.querySelectorAll<SVGPathElement>('[data-edge-id]'),
|
||||
).find((edge) => edge.getAttribute('data-edge-id') === edgeId);
|
||||
expect(edgeById(visibleEdgeId)).not.toBeNull();
|
||||
expect(edgeById(hiddenEdgeId)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('coalesces section scroll geometry, keeps partial endpoints stable, and cleans one dependency observer', async () => {
|
||||
const referenceId = 'asset-reference:["resource-a","resource-b"]';
|
||||
const graph = normalizeProjectResourceGraph({
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { createGameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import { projectResourcesFromReadModels } from '../src/view/project-development/resourceProjectionModel';
|
||||
import {
|
||||
projectResourcesFromReadModels,
|
||||
projectResourceTypeLabel,
|
||||
} from '../src/view/project-development/resourceProjectionModel';
|
||||
|
||||
describe('项目资源投影', () => {
|
||||
it('只把明确资源投影到固定分类,未知任务产物不会伪装成项目版本', () => {
|
||||
@@ -225,4 +228,119 @@ describe('项目资源投影', () => {
|
||||
expect(versions[0]?.version?.childVersionIds).toEqual(['version-child']);
|
||||
expect(versions[1]?.version?.parentVersionId).toBe('version-root');
|
||||
});
|
||||
|
||||
it('按稳定优先级推导资源卡类型标识并为未知类型兜底', () => {
|
||||
expect(
|
||||
projectResourceTypeLabel({
|
||||
category: 'version',
|
||||
subtype: 'project-version',
|
||||
path: 'versions/v1',
|
||||
mediaType: '正式项目版本',
|
||||
}),
|
||||
).toBe('项目版本');
|
||||
expect(
|
||||
projectResourceTypeLabel({
|
||||
category: 'document',
|
||||
subtype: 'agent-result',
|
||||
path: '专业 Agent 文本回执',
|
||||
mediaType: 'Agent 历史文本回执',
|
||||
}),
|
||||
).toBe('Agent 回执');
|
||||
expect(
|
||||
projectResourceTypeLabel({
|
||||
category: 'document',
|
||||
subtype: 'task-artifact',
|
||||
path: 'memory/plan.md',
|
||||
mediaType: '项目文档',
|
||||
}),
|
||||
).toBe('文档');
|
||||
expect(
|
||||
projectResourceTypeLabel({
|
||||
category: 'audio',
|
||||
subtype: 'task-artifact',
|
||||
path: 'audio/theme.wav',
|
||||
mediaType: '音乐音效产物',
|
||||
}),
|
||||
).toBe('音频');
|
||||
expect(
|
||||
projectResourceTypeLabel({
|
||||
category: 'art',
|
||||
subtype: 'task-artifact',
|
||||
path: 'assets/hero.svg',
|
||||
mediaType: '美术产物',
|
||||
}),
|
||||
).toBe('SVG');
|
||||
expect(
|
||||
projectResourceTypeLabel({
|
||||
category: 'art',
|
||||
subtype: 'task-artifact',
|
||||
path: 'assets/unknown',
|
||||
mediaType: '美术产物',
|
||||
}),
|
||||
).toBe('图片');
|
||||
expect(
|
||||
projectResourceTypeLabel({
|
||||
category: 'art',
|
||||
subtype: 'character',
|
||||
path: 'assets/hero.svg',
|
||||
mediaType: 'image/svg+xml',
|
||||
}),
|
||||
).toBe('SVG');
|
||||
expect(
|
||||
projectResourceTypeLabel({
|
||||
category: 'art',
|
||||
subtype: 'video',
|
||||
path: 'assets/intro.mp4',
|
||||
mediaType: 'video/mp4',
|
||||
}),
|
||||
).toBe('视频');
|
||||
expect(
|
||||
projectResourceTypeLabel({
|
||||
category: 'art',
|
||||
subtype: 'character',
|
||||
path: 'assets/hero.png',
|
||||
mediaType: 'image/png',
|
||||
}),
|
||||
).toBe('图片');
|
||||
expect(
|
||||
projectResourceTypeLabel({
|
||||
category: 'document',
|
||||
subtype: 'attachment',
|
||||
path: 'docs/rules.yaml',
|
||||
mediaType: 'application/yaml',
|
||||
}),
|
||||
).toBe('文档');
|
||||
expect(
|
||||
projectResourceTypeLabel({
|
||||
category: 'document',
|
||||
subtype: 'unknown',
|
||||
path: 'data/blob',
|
||||
mediaType: 'application/octet-stream',
|
||||
}),
|
||||
).toBe('文档');
|
||||
expect(
|
||||
projectResourceTypeLabel({
|
||||
category: 'code',
|
||||
subtype: 'source',
|
||||
path: 'game/main.ts',
|
||||
mediaType: 'text/typescript',
|
||||
}),
|
||||
).toBe('游戏代码');
|
||||
expect(
|
||||
projectResourceTypeLabel({
|
||||
category: 'art',
|
||||
subtype: 'unknown',
|
||||
path: 'assets/blob',
|
||||
mediaType: 'application/octet-stream',
|
||||
}),
|
||||
).toBe('图片');
|
||||
expect(
|
||||
projectResourceTypeLabel({
|
||||
category: 'unsupported' as never,
|
||||
subtype: 'unknown',
|
||||
path: 'data/blob',
|
||||
mediaType: 'application/octet-stream',
|
||||
}),
|
||||
).toBe('未知');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
RESOURCE_CANVAS_COLUMN_GAP,
|
||||
RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP,
|
||||
RESOURCE_CANVAS_DEPENDENCY_ROW_GAP,
|
||||
RESOURCE_CANVAS_INITIAL_FIT_MAX_SCALE,
|
||||
type ResourceCanvasCardSize,
|
||||
resourceCanvasContentBounds,
|
||||
resourceCanvasImageCardSize,
|
||||
@@ -831,15 +832,23 @@ describe('resource canvas variable card geometry', () => {
|
||||
expect(viewport.x + bounds.x * viewport.scale).toBeCloseTo(16, 8);
|
||||
});
|
||||
|
||||
it('caps the initial fit without limiting explicit canvas zoom', () => {
|
||||
const viewport = fitResourceCanvasViewportToContent({
|
||||
bounds: { x: 0, y: 0, width: 180, height: 128 },
|
||||
canvasSize: { width: 800, height: 600 },
|
||||
maxScale: 1.5,
|
||||
});
|
||||
it.each(['document', 'art', 'audio', 'code', 'version'])(
|
||||
'caps the initial fit for every resource category (%s) while keeping explicit zoom overrides available',
|
||||
() => {
|
||||
const viewport = fitResourceCanvasViewportToContent({
|
||||
bounds: { x: 0, y: 0, width: 180, height: 128 },
|
||||
canvasSize: { width: 800, height: 600 },
|
||||
});
|
||||
const explicitViewport = fitResourceCanvasViewportToContent({
|
||||
bounds: { x: 0, y: 0, width: 180, height: 128 },
|
||||
canvasSize: { width: 800, height: 600 },
|
||||
maxScale: 2.5,
|
||||
});
|
||||
|
||||
expect(viewport.scale).toBe(1.5);
|
||||
});
|
||||
expect(viewport.scale).toBe(RESOURCE_CANVAS_INITIAL_FIT_MAX_SCALE);
|
||||
expect(explicitViewport.scale).toBe(2.5);
|
||||
},
|
||||
);
|
||||
|
||||
it('accepts card sizes directly on resource items for hook integration', () => {
|
||||
const wide = {
|
||||
|
||||
Reference in New Issue
Block a user