将资源依赖图计算迁移到Rust
新增只读资源依赖图模型,完成过滤、去重、环检测和任务流聚合 前端SVG仅负责几何渲染、搜索联动、选中高亮和拖动预览 补充资源图、覆盖层和工作台回归测试 同步更新工作台PRD、技术方案与项目记忆
This commit is contained in:
@@ -204,6 +204,17 @@ pub(crate) fn read_local_project_resource_canvas_layout(
|
||||
read_project_resource_canvas_layout_at(Path::new(project_path.trim()), mode)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn read_local_project_resource_graph(
|
||||
project_path: String,
|
||||
expected_project_id: String,
|
||||
resources: Vec<ProjectResourceGraphNodeInput>,
|
||||
) -> Result<ProjectResourceGraphReadModel, String> {
|
||||
let root = validated_local_project_directory_path(project_path.trim())?;
|
||||
enforce_project_auto_permission_policy(&root, "asset.list")?;
|
||||
read_project_resource_graph_at(&root, expected_project_id.trim(), resources)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn update_local_project_resource_canvas_layout(
|
||||
project_path: String,
|
||||
|
||||
@@ -1969,6 +1969,7 @@ fn handle_game_creator_gui_run_event(event: &tauri::RunEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
fn main() {
|
||||
let mut args = std::env::args().skip(1).collect::<Vec<_>>();
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -2322,6 +2323,7 @@ fn main() {
|
||||
stop_local_game_preview_if_matches,
|
||||
get_local_game_preview_status,
|
||||
read_local_project_resource_canvas_layout,
|
||||
read_local_project_resource_graph,
|
||||
update_local_project_resource_canvas_layout,
|
||||
get_local_game_project_revision,
|
||||
get_local_game_manifest
|
||||
|
||||
@@ -10,6 +10,7 @@ mod export;
|
||||
mod filesystem;
|
||||
mod manifest;
|
||||
mod memory;
|
||||
mod resource_dependency_graph;
|
||||
mod resource_layout;
|
||||
mod verification;
|
||||
|
||||
@@ -20,5 +21,6 @@ pub(crate) use export::*;
|
||||
pub(crate) use filesystem::*;
|
||||
pub(crate) use manifest::*;
|
||||
pub(crate) use memory::*;
|
||||
pub(crate) use resource_dependency_graph::*;
|
||||
pub(crate) use resource_layout::*;
|
||||
pub(crate) use verification::*;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+224
-76
@@ -27,9 +27,14 @@ type Rect = Point & {
|
||||
|
||||
type SectionOrigins = Partial<Record<ProjectResourceCanvasSection, Point>>;
|
||||
|
||||
type RectLookup = {
|
||||
get(resourceId: string): Rect | undefined;
|
||||
};
|
||||
|
||||
export type ResourceDependencyOverlayProps = {
|
||||
graph: ProjectResourceGraph;
|
||||
positions: readonly ProjectResourceCanvasPosition[];
|
||||
dragPreview: (Point & { resourceId: string }) | null;
|
||||
visibleResourceIds: ReadonlySet<string>;
|
||||
selectedResourceId: string | null;
|
||||
};
|
||||
@@ -117,7 +122,7 @@ function rectAnchor(rect: Rect, direction: 1 | -1): Point {
|
||||
|
||||
function referenceGeometry(
|
||||
edge: ProjectResourceReferenceEdge,
|
||||
rectByResourceId: ReadonlyMap<string, Rect>,
|
||||
rectByResourceId: RectLookup,
|
||||
) {
|
||||
const sourceRect = rectByResourceId.get(edge.sourceResourceId);
|
||||
const targetRect = rectByResourceId.get(edge.targetResourceId);
|
||||
@@ -148,7 +153,7 @@ function referenceGeometry(
|
||||
|
||||
function taskFlowGeometry(
|
||||
flow: ProjectResourceTaskFlow,
|
||||
rectByResourceId: ReadonlyMap<string, Rect>,
|
||||
rectByResourceId: RectLookup,
|
||||
) {
|
||||
const sourceRects = flow.sourceResourceIds.flatMap((resourceId) => {
|
||||
const rect = rectByResourceId.get(resourceId);
|
||||
@@ -198,11 +203,15 @@ function taskFlowGeometry(
|
||||
export function ResourceDependencyOverlay({
|
||||
graph,
|
||||
positions,
|
||||
dragPreview,
|
||||
visibleResourceIds,
|
||||
selectedResourceId,
|
||||
}: ResourceDependencyOverlayProps) {
|
||||
const markerPrefix = useId().replace(/[^a-zA-Z0-9_-]/gu, '');
|
||||
const overlayRef = useRef<SVGSVGElement>(null);
|
||||
const referencePathRefs = useRef(new Map<string, SVGPathElement>());
|
||||
const taskFlowGroupRefs = useRef(new Map<string, SVGGElement>());
|
||||
const activeDragResourceIdRef = useRef<string | null>(null);
|
||||
const [sectionOrigins, setSectionOrigins] = useState<SectionOrigins>({});
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@@ -257,7 +266,7 @@ export function ResourceDependencyOverlay({
|
||||
observer?.disconnect();
|
||||
window.removeEventListener('resize', scheduleMeasure);
|
||||
};
|
||||
}, [graph, positions, visibleResourceIds]);
|
||||
}, []);
|
||||
|
||||
const rectByResourceId = useMemo(() => {
|
||||
const result = new Map<string, Rect>();
|
||||
@@ -290,6 +299,216 @@ export function ResourceDependencyOverlay({
|
||||
selectedResourceId && graph.resourceIds.has(selectedResourceId),
|
||||
);
|
||||
|
||||
const renderTaskFlows = useMemo(
|
||||
() =>
|
||||
graph.taskFlows.map((flow) => {
|
||||
const geometry = taskFlowGeometry(flow, rectByResourceId);
|
||||
if (!geometry) {
|
||||
return null;
|
||||
}
|
||||
const highlighted = neighbors.connectedEdgeIds.has(flow.id);
|
||||
const className = `game-resource-dependency-edge game-resource-dependency-edge--task${
|
||||
highlighted ? ' is-highlighted' : selected ? ' is-dimmed' : ''
|
||||
}${flow.cyclic ? ' is-cyclic' : ''}`;
|
||||
return (
|
||||
<g
|
||||
ref={(node) => {
|
||||
if (node) {
|
||||
taskFlowGroupRefs.current.set(flow.id, node);
|
||||
} else {
|
||||
taskFlowGroupRefs.current.delete(flow.id);
|
||||
}
|
||||
}}
|
||||
key={flow.id}
|
||||
className={className}
|
||||
data-edge-kind="task-flow"
|
||||
data-edge-id={flow.id}
|
||||
data-source-task-id={flow.sourceTaskId}
|
||||
data-target-task-id={flow.targetTaskId}
|
||||
data-cyclic={flow.cyclic || undefined}
|
||||
>
|
||||
<title>{`任务流转:${flow.sourceTaskId} → ${flow.targetTaskId}${
|
||||
flow.cyclic ? '(检测到依赖环)' : ''
|
||||
}`}</title>
|
||||
{geometry.sourceAnchors.map(({ resourceId, point }) => (
|
||||
<path
|
||||
key={`source:${resourceId}`}
|
||||
className="game-resource-dependency-branch"
|
||||
data-branch-side="source"
|
||||
data-resource-id={resourceId}
|
||||
d={taskFlowBranchPath(point, geometry.sourceHub)}
|
||||
/>
|
||||
))}
|
||||
<path
|
||||
className="game-resource-dependency-trunk"
|
||||
d={connectionPath(geometry.sourceHub, geometry.targetHub)}
|
||||
/>
|
||||
{geometry.targetAnchors.map(({ resourceId, point }) => (
|
||||
<path
|
||||
key={`target:${resourceId}`}
|
||||
className="game-resource-dependency-branch"
|
||||
data-branch-side="target"
|
||||
data-resource-id={resourceId}
|
||||
d={taskFlowBranchPath(geometry.targetHub, point)}
|
||||
markerEnd={`url(#${markerPrefix}-task-flow-arrow)`}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
}),
|
||||
[
|
||||
graph.taskFlows,
|
||||
markerPrefix,
|
||||
neighbors.connectedEdgeIds,
|
||||
rectByResourceId,
|
||||
selected,
|
||||
],
|
||||
);
|
||||
|
||||
const renderReferenceEdges = useMemo(
|
||||
() =>
|
||||
graph.referenceEdges.map((edge) => {
|
||||
const geometry = referenceGeometry(edge, rectByResourceId);
|
||||
if (!geometry) {
|
||||
return null;
|
||||
}
|
||||
const highlighted = neighbors.connectedEdgeIds.has(edge.id);
|
||||
const className = `game-resource-dependency-edge game-resource-dependency-edge--reference${
|
||||
highlighted ? ' is-highlighted' : selected ? ' is-dimmed' : ''
|
||||
}${edge.cyclic ? ' is-cyclic' : ''}`;
|
||||
return (
|
||||
<path
|
||||
ref={(node) => {
|
||||
if (node) {
|
||||
referencePathRefs.current.set(edge.id, node);
|
||||
} else {
|
||||
referencePathRefs.current.delete(edge.id);
|
||||
}
|
||||
}}
|
||||
key={edge.id}
|
||||
className={className}
|
||||
data-edge-kind="asset-reference"
|
||||
data-edge-id={edge.id}
|
||||
data-source-resource-id={edge.sourceResourceId}
|
||||
data-target-resource-id={edge.targetResourceId}
|
||||
data-cyclic={edge.cyclic || undefined}
|
||||
data-self-loop={geometry.selfLoop || undefined}
|
||||
d={geometry.path}
|
||||
markerEnd={`url(#${markerPrefix}-asset-reference-arrow)`}
|
||||
>
|
||||
<title>{`资源引用${edge.cyclic ? '(检测到依赖环)' : ''}`}</title>
|
||||
</path>
|
||||
);
|
||||
}),
|
||||
[
|
||||
graph.referenceEdges,
|
||||
markerPrefix,
|
||||
neighbors.connectedEdgeIds,
|
||||
rectByResourceId,
|
||||
selected,
|
||||
],
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const affectedResourceIds = new Set<string>();
|
||||
if (activeDragResourceIdRef.current) {
|
||||
affectedResourceIds.add(activeDragResourceIdRef.current);
|
||||
}
|
||||
if (dragPreview) {
|
||||
affectedResourceIds.add(dragPreview.resourceId);
|
||||
}
|
||||
activeDragResourceIdRef.current = dragPreview?.resourceId ?? null;
|
||||
if (affectedResourceIds.size === 0) {
|
||||
return;
|
||||
}
|
||||
const dragBasePosition = dragPreview
|
||||
? positions.find(
|
||||
(position) => position.resourceId === dragPreview.resourceId,
|
||||
)
|
||||
: undefined;
|
||||
const rectLookup = {
|
||||
get(resourceId: string) {
|
||||
const rect = rectByResourceId.get(resourceId);
|
||||
if (!rect) {
|
||||
return undefined;
|
||||
}
|
||||
return dragPreview?.resourceId === resourceId
|
||||
? {
|
||||
...rect,
|
||||
x:
|
||||
rect.x - (dragBasePosition?.x ?? 0) + dragPreview.x,
|
||||
y:
|
||||
rect.y - (dragBasePosition?.y ?? 0) + dragPreview.y,
|
||||
}
|
||||
: rect;
|
||||
},
|
||||
};
|
||||
const affectedEdgeIds = new Set<string>();
|
||||
for (const resourceId of affectedResourceIds) {
|
||||
const index = graph.connectionIndex.get(resourceId);
|
||||
index?.referenceEdgeIds.forEach((edgeId) =>
|
||||
affectedEdgeIds.add(edgeId),
|
||||
);
|
||||
index?.taskFlowIds.forEach((flowId) => affectedEdgeIds.add(flowId));
|
||||
}
|
||||
for (const edgeId of affectedEdgeIds) {
|
||||
const referenceEdge = graph.referenceEdgeById.get(edgeId);
|
||||
if (referenceEdge) {
|
||||
const geometry = referenceGeometry(referenceEdge, rectLookup);
|
||||
const path = referencePathRefs.current.get(edgeId);
|
||||
if (geometry && path) {
|
||||
path.setAttribute('d', geometry.path);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const flow = graph.taskFlowById.get(edgeId);
|
||||
const group = taskFlowGroupRefs.current.get(edgeId);
|
||||
if (!flow || !group) {
|
||||
continue;
|
||||
}
|
||||
const geometry = taskFlowGeometry(flow, rectLookup);
|
||||
if (!geometry) {
|
||||
continue;
|
||||
}
|
||||
const sourcePathByResourceId = new Map<string, SVGPathElement>();
|
||||
const targetPathByResourceId = new Map<string, SVGPathElement>();
|
||||
group
|
||||
.querySelectorAll<SVGPathElement>('.game-resource-dependency-branch')
|
||||
.forEach((path) => {
|
||||
const resourceId = path.dataset.resourceId;
|
||||
if (!resourceId) {
|
||||
return;
|
||||
}
|
||||
(path.dataset.branchSide === 'source'
|
||||
? sourcePathByResourceId
|
||||
: targetPathByResourceId
|
||||
).set(resourceId, path);
|
||||
});
|
||||
geometry.sourceAnchors.forEach(({ resourceId, point }) => {
|
||||
sourcePathByResourceId
|
||||
.get(resourceId)
|
||||
?.setAttribute(
|
||||
'd',
|
||||
taskFlowBranchPath(point, geometry.sourceHub),
|
||||
);
|
||||
});
|
||||
group
|
||||
.querySelector<SVGPathElement>('.game-resource-dependency-trunk')
|
||||
?.setAttribute(
|
||||
'd',
|
||||
connectionPath(geometry.sourceHub, geometry.targetHub),
|
||||
);
|
||||
geometry.targetAnchors.forEach(({ resourceId, point }) => {
|
||||
targetPathByResourceId
|
||||
.get(resourceId)
|
||||
?.setAttribute(
|
||||
'd',
|
||||
taskFlowBranchPath(geometry.targetHub, point),
|
||||
);
|
||||
});
|
||||
}
|
||||
}, [dragPreview, graph, positions, rectByResourceId]);
|
||||
|
||||
return (
|
||||
<svg
|
||||
ref={overlayRef}
|
||||
@@ -327,79 +546,8 @@ export function ResourceDependencyOverlay({
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
{graph.taskFlows.map((flow) => {
|
||||
const geometry = taskFlowGeometry(flow, rectByResourceId);
|
||||
if (!geometry) {
|
||||
return null;
|
||||
}
|
||||
const highlighted = neighbors.connectedEdgeIds.has(flow.id);
|
||||
const className = `game-resource-dependency-edge game-resource-dependency-edge--task${
|
||||
highlighted ? ' is-highlighted' : selected ? ' is-dimmed' : ''
|
||||
}${flow.cyclic ? ' is-cyclic' : ''}`;
|
||||
return (
|
||||
<g
|
||||
key={flow.id}
|
||||
className={className}
|
||||
data-edge-kind="task-flow"
|
||||
data-edge-id={flow.id}
|
||||
data-source-task-id={flow.sourceTaskId}
|
||||
data-target-task-id={flow.targetTaskId}
|
||||
data-cyclic={flow.cyclic || undefined}
|
||||
>
|
||||
<title>{`任务流转:${flow.sourceTaskId} → ${flow.targetTaskId}${
|
||||
flow.cyclic ? '(检测到依赖环)' : ''
|
||||
}`}</title>
|
||||
{geometry.sourceAnchors.map(({ resourceId, point }) => (
|
||||
<path
|
||||
key={`source:${resourceId}`}
|
||||
className="game-resource-dependency-branch"
|
||||
data-resource-id={resourceId}
|
||||
d={taskFlowBranchPath(point, geometry.sourceHub)}
|
||||
/>
|
||||
))}
|
||||
<path
|
||||
className="game-resource-dependency-trunk"
|
||||
d={connectionPath(geometry.sourceHub, geometry.targetHub)}
|
||||
/>
|
||||
{geometry.targetAnchors.map(({ resourceId, point }) => (
|
||||
<path
|
||||
key={`target:${resourceId}`}
|
||||
className="game-resource-dependency-branch"
|
||||
data-resource-id={resourceId}
|
||||
d={taskFlowBranchPath(geometry.targetHub, point)}
|
||||
markerEnd={`url(#${markerPrefix}-task-flow-arrow)`}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{graph.referenceEdges.map((edge) => {
|
||||
const geometry = referenceGeometry(edge, rectByResourceId);
|
||||
if (!geometry) {
|
||||
return null;
|
||||
}
|
||||
const highlighted = neighbors.connectedEdgeIds.has(edge.id);
|
||||
const className = `game-resource-dependency-edge game-resource-dependency-edge--reference${
|
||||
highlighted ? ' is-highlighted' : selected ? ' is-dimmed' : ''
|
||||
}${edge.cyclic ? ' is-cyclic' : ''}`;
|
||||
return (
|
||||
<path
|
||||
key={edge.id}
|
||||
className={className}
|
||||
data-edge-kind="asset-reference"
|
||||
data-edge-id={edge.id}
|
||||
data-source-resource-id={edge.sourceResourceId}
|
||||
data-target-resource-id={edge.targetResourceId}
|
||||
data-cyclic={edge.cyclic || undefined}
|
||||
data-self-loop={geometry.selfLoop || undefined}
|
||||
d={geometry.path}
|
||||
markerEnd={`url(#${markerPrefix}-asset-reference-arrow)`}
|
||||
>
|
||||
<title>{`资源引用${edge.cyclic ? '(检测到依赖环)' : ''}`}</title>
|
||||
</path>
|
||||
);
|
||||
})}
|
||||
{renderTaskFlows}
|
||||
{renderReferenceEdges}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -43,12 +43,20 @@ import {
|
||||
resolveEmbeddedPreviewUrl,
|
||||
} from '../../features/project-workspace/LocalGamePreviewFrame';
|
||||
import {
|
||||
RESOURCE_CANVAS_CARD_HEIGHT,
|
||||
RESOURCE_CANVAS_CARD_WIDTH,
|
||||
RESOURCE_CANVAS_COLUMN_GAP,
|
||||
RESOURCE_CANVAS_DRAG_THRESHOLD,
|
||||
RESOURCE_CANVAS_ROW_GAP,
|
||||
resourceCanvasSectionExtent,
|
||||
} from './resourceCanvasLayoutModel';
|
||||
import {
|
||||
createProjectResourceGraph,
|
||||
EMPTY_PROJECT_RESOURCE_GRAPH,
|
||||
normalizeProjectResourceGraph,
|
||||
type ProjectResourceGraph,
|
||||
projectResourceGraphNeighbors,
|
||||
type ProjectResourceGraphNodeInput,
|
||||
type ProjectResourceGraphReadModel,
|
||||
} from './resourceDependencyGraphModel';
|
||||
import { ResourceDependencyOverlay } from './ResourceDependencyOverlay';
|
||||
import { useProjectResourceCanvasLayout } from './useProjectResourceCanvasLayout';
|
||||
@@ -91,6 +99,7 @@ type ProjectResource = {
|
||||
mediaType: string;
|
||||
sourceLabel: string;
|
||||
taskTitle: string | null;
|
||||
manifestAssetId: string | null;
|
||||
producerTaskId: string | null;
|
||||
externalResourceId: string | null;
|
||||
referenceResourceIds: string[];
|
||||
@@ -139,6 +148,7 @@ export type ProjectAgentRuntimeSummary = {
|
||||
|
||||
const emptyProjectAgentRuntimeSummaries: ProjectAgentRuntimeSummary[] = [];
|
||||
const emptyProjectAgentResults: ProjectAgentResultSummary[] = [];
|
||||
const RESOURCE_DEPENDENCY_VISUAL_GUTTER = 64;
|
||||
|
||||
type AgentSummary = ProjectAgentRuntimeSummary;
|
||||
|
||||
@@ -318,6 +328,7 @@ function resourcesFromProject(
|
||||
mediaType: category === 'document' ? '项目文档' : '项目产物',
|
||||
sourceLabel: '任务产物',
|
||||
taskTitle: task.title,
|
||||
manifestAssetId: null,
|
||||
producerTaskId: task.id,
|
||||
externalResourceId: null,
|
||||
referenceResourceIds: [],
|
||||
@@ -328,9 +339,6 @@ function resourcesFromProject(
|
||||
}
|
||||
|
||||
for (const asset of manifest.assets) {
|
||||
const task = asset.source.taskId
|
||||
? taskById.get(asset.source.taskId)
|
||||
: undefined;
|
||||
const isPendingUiPrototype =
|
||||
asset.kind === 'ui-prototype' &&
|
||||
taskById.get('design-foundation')?.status !== 'completed';
|
||||
@@ -351,12 +359,13 @@ function resourcesFromProject(
|
||||
: asset.source.kind === 'generated'
|
||||
? 'Agent 生成'
|
||||
: '用户上传',
|
||||
taskTitle: task?.title ?? null,
|
||||
producerTaskId: task?.id ?? null,
|
||||
taskTitle: null,
|
||||
manifestAssetId: asset.id,
|
||||
producerTaskId: null,
|
||||
externalResourceId: asset.source.resourceId ?? null,
|
||||
referenceResourceIds: asset.source.referenceResourceIds ?? [],
|
||||
dependencies: task?.dependencies ?? [],
|
||||
dependencyDepth: task ? taskDependencyDepth(task, taskById) : 0,
|
||||
dependencies: [],
|
||||
dependencyDepth: 0,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -376,6 +385,7 @@ function resourcesFromProject(
|
||||
mediaType: attachment.mediaType || '未知媒体类型',
|
||||
sourceLabel: '用户上传',
|
||||
taskTitle: null,
|
||||
manifestAssetId: null,
|
||||
producerTaskId: null,
|
||||
externalResourceId: null,
|
||||
referenceResourceIds: [],
|
||||
@@ -394,6 +404,7 @@ function resourcesFromProject(
|
||||
mediaType: 'Agent 历史文本回执',
|
||||
sourceLabel: `历史成果 · ${result.label}`,
|
||||
taskTitle: null,
|
||||
manifestAssetId: null,
|
||||
producerTaskId: taskById.has(result.agentId) ? result.agentId : null,
|
||||
externalResourceId: null,
|
||||
referenceResourceIds: [],
|
||||
@@ -552,6 +563,10 @@ export default function ProjectDevelopmentView({
|
||||
const dockRef = useRef<HTMLElement>(null);
|
||||
const resourceDialogRef = useRef<HTMLElement>(null);
|
||||
const resourceCardDragRef = useRef<ResourceCardDrag | null>(null);
|
||||
const pendingResourceDragPreviewRef = useRef<
|
||||
(Point & { resourceId: string }) | null
|
||||
>(null);
|
||||
const resourceDragFrameRef = useRef<number | null>(null);
|
||||
const suppressResourceClickRef = useRef<string | null>(null);
|
||||
const resourceDialogDragRef = useRef<{
|
||||
pointerId: number;
|
||||
@@ -566,10 +581,121 @@ export default function ProjectDevelopmentView({
|
||||
manifest.tasks.some(
|
||||
(task) => task.id === 'code-prototype' && task.status === 'completed',
|
||||
);
|
||||
const resources = useMemo(
|
||||
const projectedResources = useMemo(
|
||||
() => resourcesFromProject(manifest, attachments, agentResults),
|
||||
[agentResults, attachments, manifest],
|
||||
);
|
||||
const resourceGraphInputs = useMemo<ProjectResourceGraphNodeInput[]>(
|
||||
() =>
|
||||
projectedResources.map((resource) => ({
|
||||
resourceId: resource.id,
|
||||
manifestAssetId: resource.manifestAssetId,
|
||||
producerTaskId: resource.producerTaskId,
|
||||
})),
|
||||
[projectedResources],
|
||||
);
|
||||
const resourceGraphScopeKey = useMemo(
|
||||
() =>
|
||||
JSON.stringify([
|
||||
projectPath,
|
||||
manifest.projectId,
|
||||
resourceGraphInputs,
|
||||
]),
|
||||
[manifest.projectId, projectPath, resourceGraphInputs],
|
||||
);
|
||||
const [resourceGraphState, setResourceGraphState] = useState<{
|
||||
scopeKey: string;
|
||||
graph: ProjectResourceGraph;
|
||||
}>({ scopeKey: '', graph: EMPTY_PROJECT_RESOURCE_GRAPH });
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (sortMode !== 'dependency') {
|
||||
setResourceGraphState({
|
||||
scopeKey: '',
|
||||
graph: EMPTY_PROJECT_RESOURCE_GRAPH,
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
const invoke = window.__TAURI__?.core?.invoke;
|
||||
if (!invoke) {
|
||||
setResourceGraphState({
|
||||
scopeKey: resourceGraphScopeKey,
|
||||
graph: EMPTY_PROJECT_RESOURCE_GRAPH,
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
void invoke<ProjectResourceGraphReadModel>(
|
||||
'read_local_project_resource_graph',
|
||||
{
|
||||
projectPath,
|
||||
expectedProjectId: manifest.projectId,
|
||||
resources: resourceGraphInputs,
|
||||
},
|
||||
)
|
||||
.then((readModel) => {
|
||||
if (!cancelled) {
|
||||
setResourceGraphState({
|
||||
scopeKey: resourceGraphScopeKey,
|
||||
graph: normalizeProjectResourceGraph(readModel),
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setResourceGraphState({
|
||||
scopeKey: resourceGraphScopeKey,
|
||||
graph: EMPTY_PROJECT_RESOURCE_GRAPH,
|
||||
});
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
manifest.projectId,
|
||||
projectPath,
|
||||
resourceGraphInputs,
|
||||
resourceGraphScopeKey,
|
||||
sortMode,
|
||||
]);
|
||||
|
||||
const resourceGraph =
|
||||
resourceGraphState.scopeKey === resourceGraphScopeKey
|
||||
? resourceGraphState.graph
|
||||
: EMPTY_PROJECT_RESOURCE_GRAPH;
|
||||
const manifestTaskById = useMemo(
|
||||
() => new Map(manifest.tasks.map((task) => [task.id, task])),
|
||||
[manifest.tasks],
|
||||
);
|
||||
const resources = useMemo(
|
||||
() =>
|
||||
projectedResources.map((resource) => {
|
||||
const producerTaskId =
|
||||
resourceGraph.producerTaskIdByResourceId.get(resource.id) ??
|
||||
resource.producerTaskId;
|
||||
const producerTask = producerTaskId
|
||||
? manifestTaskById.get(producerTaskId)
|
||||
: undefined;
|
||||
return producerTask && producerTaskId !== resource.producerTaskId
|
||||
? {
|
||||
...resource,
|
||||
taskTitle: producerTask.title,
|
||||
producerTaskId,
|
||||
dependencies: producerTask.dependencies,
|
||||
dependencyDepth: taskDependencyDepth(
|
||||
producerTask,
|
||||
manifestTaskById,
|
||||
),
|
||||
}
|
||||
: resource;
|
||||
}),
|
||||
[manifestTaskById, projectedResources, resourceGraph],
|
||||
);
|
||||
const {
|
||||
layout: resourceLayout,
|
||||
notice: resourceLayoutNotice,
|
||||
@@ -581,12 +707,15 @@ export default function ProjectDevelopmentView({
|
||||
mode: sortMode,
|
||||
resources,
|
||||
});
|
||||
const resourcePositionById = new Map(
|
||||
resourceLayout.positions.map((position) => [position.resourceId, position]),
|
||||
);
|
||||
const resourceGraph = useMemo(
|
||||
() => createProjectResourceGraph(resources, manifest.tasks),
|
||||
[manifest.tasks, resources],
|
||||
const resourcePositionById = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
resourceLayout.positions.map((position) => [
|
||||
position.resourceId,
|
||||
position,
|
||||
]),
|
||||
),
|
||||
[resourceLayout.positions],
|
||||
);
|
||||
const selectedResourceNeighbors = useMemo(
|
||||
() => projectResourceGraphNeighbors(resourceGraph, selectedResourceId),
|
||||
@@ -611,18 +740,41 @@ export default function ProjectDevelopmentView({
|
||||
() => new Set(visibleResources.map((resource) => resource.id)),
|
||||
[visibleResources],
|
||||
);
|
||||
const resourceDependencyPositions = useMemo(
|
||||
const visibleResourcesByCategory = useMemo(
|
||||
() =>
|
||||
resourceLayout.positions.map((position) =>
|
||||
resourceDragPreview?.resourceId === position.resourceId
|
||||
? {
|
||||
...position,
|
||||
x: resourceDragPreview.x,
|
||||
y: resourceDragPreview.y,
|
||||
}
|
||||
: position,
|
||||
new Map(
|
||||
categoryOrder.map((category) => [
|
||||
category,
|
||||
visibleResources.filter(
|
||||
(resource) => resource.category === category,
|
||||
),
|
||||
]),
|
||||
),
|
||||
[resourceDragPreview, resourceLayout.positions],
|
||||
[visibleResources],
|
||||
);
|
||||
const resourcePositionsByCategory = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
categoryOrder.map((category) => [
|
||||
category,
|
||||
resourceLayout.positions.filter(
|
||||
(position) => position.section === category,
|
||||
),
|
||||
]),
|
||||
),
|
||||
[resourceLayout.positions],
|
||||
);
|
||||
const resourceBaseExtentByCategory = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
categoryOrder.map((category) => [
|
||||
category,
|
||||
resourceCanvasSectionExtent(
|
||||
resourcePositionsByCategory.get(category) ?? [],
|
||||
),
|
||||
]),
|
||||
),
|
||||
[resourcePositionsByCategory],
|
||||
);
|
||||
const selectedResource =
|
||||
resources.find((resource) => resource.id === selectedResourceId) ?? null;
|
||||
@@ -665,6 +817,39 @@ export default function ProjectDevelopmentView({
|
||||
approvalOptions.find((option) => option.id === approvalMode)?.label ??
|
||||
'严格审批';
|
||||
|
||||
const scheduleResourceDragPreview = useCallback(
|
||||
(preview: Point & { resourceId: string }) => {
|
||||
pendingResourceDragPreviewRef.current = preview;
|
||||
if (resourceDragFrameRef.current !== null) {
|
||||
return;
|
||||
}
|
||||
resourceDragFrameRef.current = window.requestAnimationFrame(() => {
|
||||
resourceDragFrameRef.current = null;
|
||||
const pending = pendingResourceDragPreviewRef.current;
|
||||
pendingResourceDragPreviewRef.current = null;
|
||||
if (pending) {
|
||||
setResourceDragPreview(pending);
|
||||
}
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const clearScheduledResourceDragPreview = useCallback(() => {
|
||||
pendingResourceDragPreviewRef.current = null;
|
||||
if (resourceDragFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(resourceDragFrameRef.current);
|
||||
resourceDragFrameRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
clearScheduledResourceDragPreview();
|
||||
},
|
||||
[clearScheduledResourceDragPreview],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (embeddedPreviewUrl) {
|
||||
setMode('run');
|
||||
@@ -674,9 +859,10 @@ export default function ProjectDevelopmentView({
|
||||
useEffect(() => {
|
||||
setSelectedResourceId(null);
|
||||
resourceCardDragRef.current = null;
|
||||
clearScheduledResourceDragPreview();
|
||||
setDraggedResourceId(null);
|
||||
setResourceDragPreview(null);
|
||||
}, [projectPath]);
|
||||
}, [clearScheduledResourceDragPreview, projectPath]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedResourceId) {
|
||||
@@ -812,6 +998,7 @@ export default function ProjectDevelopmentView({
|
||||
if (!position || position.section !== resource.category) {
|
||||
return;
|
||||
}
|
||||
clearScheduledResourceDragPreview();
|
||||
resourceCardDragRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
resourceId: resource.id,
|
||||
@@ -842,7 +1029,7 @@ export default function ProjectDevelopmentView({
|
||||
}
|
||||
drag.moved = true;
|
||||
setDraggedResourceId(drag.resourceId);
|
||||
setResourceDragPreview({
|
||||
scheduleResourceDragPreview({
|
||||
resourceId: drag.resourceId,
|
||||
x: Math.max(0, drag.startX + deltaX),
|
||||
y: Math.max(0, drag.startY + deltaY),
|
||||
@@ -858,6 +1045,7 @@ export default function ProjectDevelopmentView({
|
||||
if (!drag || drag.pointerId !== event.pointerId) {
|
||||
return;
|
||||
}
|
||||
clearScheduledResourceDragPreview();
|
||||
resourceCardDragRef.current = null;
|
||||
if (event.currentTarget.hasPointerCapture?.(event.pointerId)) {
|
||||
event.currentTarget.releasePointerCapture?.(event.pointerId);
|
||||
@@ -1050,29 +1238,49 @@ export default function ProjectDevelopmentView({
|
||||
<ResourceDependencyOverlay
|
||||
key={`${projectPath}:${manifest.projectId}`}
|
||||
graph={resourceGraph}
|
||||
positions={resourceDependencyPositions}
|
||||
positions={resourceLayout.positions}
|
||||
dragPreview={resourceDragPreview}
|
||||
visibleResourceIds={visibleResourceIds}
|
||||
selectedResourceId={selectedResourceId}
|
||||
/>
|
||||
) : null}
|
||||
{categoryOrder.map((category) => {
|
||||
const categoryResources = visibleResources.filter(
|
||||
(resource) => resource.category === category,
|
||||
);
|
||||
const categoryPositions = resourceLayout.positions.filter(
|
||||
(position) => position.section === category,
|
||||
);
|
||||
const extent = resourceCanvasSectionExtent(
|
||||
categoryPositions.map((position) =>
|
||||
resourceDragPreview?.resourceId === position.resourceId
|
||||
? {
|
||||
...position,
|
||||
x: resourceDragPreview.x,
|
||||
y: resourceDragPreview.y,
|
||||
}
|
||||
: position,
|
||||
const categoryResources =
|
||||
visibleResourcesByCategory.get(category) ?? [];
|
||||
const baseExtent = resourceBaseExtentByCategory.get(
|
||||
category,
|
||||
) ?? { width: 0, height: 0 };
|
||||
const dragPosition = resourceDragPreview
|
||||
? resourcePositionById.get(
|
||||
resourceDragPreview.resourceId,
|
||||
)
|
||||
: undefined;
|
||||
const previewInCategory =
|
||||
dragPosition?.section === category
|
||||
? resourceDragPreview
|
||||
: null;
|
||||
const extent = {
|
||||
width:
|
||||
Math.max(
|
||||
baseExtent.width,
|
||||
previewInCategory
|
||||
? previewInCategory.x +
|
||||
RESOURCE_CANVAS_CARD_WIDTH +
|
||||
RESOURCE_CANVAS_COLUMN_GAP
|
||||
: 0,
|
||||
) +
|
||||
(sortMode === 'dependency'
|
||||
? RESOURCE_DEPENDENCY_VISUAL_GUTTER
|
||||
: 0),
|
||||
height: Math.max(
|
||||
baseExtent.height,
|
||||
previewInCategory
|
||||
? previewInCategory.y +
|
||||
RESOURCE_CANVAS_CARD_HEIGHT +
|
||||
RESOURCE_CANVAS_ROW_GAP
|
||||
: 0,
|
||||
),
|
||||
);
|
||||
};
|
||||
const Icon = categoryIcons[category];
|
||||
return (
|
||||
<section
|
||||
|
||||
+152
-253
@@ -1,13 +1,7 @@
|
||||
export type ProjectResourceGraphNodeInput = {
|
||||
id: string;
|
||||
resourceId: string;
|
||||
manifestAssetId: string | null;
|
||||
producerTaskId: string | null;
|
||||
externalResourceId: string | null;
|
||||
referenceResourceIds: readonly string[];
|
||||
};
|
||||
|
||||
export type ProjectResourceGraphTaskInput = {
|
||||
id: string;
|
||||
dependencies: readonly string[];
|
||||
};
|
||||
|
||||
export type ProjectResourceReferenceEdge = {
|
||||
@@ -28,13 +22,50 @@ export type ProjectResourceTaskFlow = {
|
||||
cyclic: boolean;
|
||||
};
|
||||
|
||||
export type ProjectResourceConnectionIndexDto = {
|
||||
resourceId: string;
|
||||
upstreamReferenceResourceIds: string[];
|
||||
downstreamReferenceResourceIds: string[];
|
||||
referenceEdgeIds: string[];
|
||||
taskFlowIds: string[];
|
||||
};
|
||||
|
||||
export type ProjectResourceProducerAssignment = {
|
||||
resourceId: string;
|
||||
taskId: string;
|
||||
};
|
||||
|
||||
export type ProjectResourceGraphReadModel = {
|
||||
resourceIds: string[];
|
||||
referenceEdges: ProjectResourceReferenceEdge[];
|
||||
taskFlows: ProjectResourceTaskFlow[];
|
||||
connectionIndex: ProjectResourceConnectionIndexDto[];
|
||||
producerAssignments: ProjectResourceProducerAssignment[];
|
||||
unresolvedReferenceResourceIds: string[];
|
||||
cyclicResourceIds: string[];
|
||||
cyclicTaskIds: string[];
|
||||
producerMappingTruncated: boolean;
|
||||
};
|
||||
|
||||
type ProjectResourceConnectionIndex = {
|
||||
upstreamReferenceResourceIds: ReadonlySet<string>;
|
||||
downstreamReferenceResourceIds: ReadonlySet<string>;
|
||||
referenceEdgeIds: ReadonlySet<string>;
|
||||
taskFlowIds: ReadonlySet<string>;
|
||||
};
|
||||
|
||||
export type ProjectResourceGraph = {
|
||||
resourceIds: ReadonlySet<string>;
|
||||
referenceEdges: ProjectResourceReferenceEdge[];
|
||||
referenceEdgeById: ReadonlyMap<string, ProjectResourceReferenceEdge>;
|
||||
taskFlows: ProjectResourceTaskFlow[];
|
||||
taskFlowById: ReadonlyMap<string, ProjectResourceTaskFlow>;
|
||||
connectionIndex: ReadonlyMap<string, ProjectResourceConnectionIndex>;
|
||||
producerTaskIdByResourceId: ReadonlyMap<string, string>;
|
||||
unresolvedReferenceResourceIds: string[];
|
||||
cyclicResourceIds: ReadonlySet<string>;
|
||||
cyclicTaskIds: ReadonlySet<string>;
|
||||
producerMappingTruncated: boolean;
|
||||
};
|
||||
|
||||
export type ProjectResourceGraphNeighbors = {
|
||||
@@ -43,18 +74,18 @@ export type ProjectResourceGraphNeighbors = {
|
||||
connectedEdgeIds: ReadonlySet<string>;
|
||||
};
|
||||
|
||||
type DirectedEdge = {
|
||||
id: string;
|
||||
sourceId: string;
|
||||
targetId: string;
|
||||
};
|
||||
|
||||
type CycleAnalysis = {
|
||||
cyclicNodeIds: Set<string>;
|
||||
cyclicEdgeIds: Set<string>;
|
||||
};
|
||||
|
||||
const emptyStringSet: ReadonlySet<string> = new Set<string>();
|
||||
const emptyStringMap: ReadonlyMap<string, string> = new Map<string, string>();
|
||||
const emptyConnectionMap: ReadonlyMap<string, ProjectResourceConnectionIndex> =
|
||||
new Map<string, ProjectResourceConnectionIndex>();
|
||||
const emptyTaskFlowMap: ReadonlyMap<string, ProjectResourceTaskFlow> = new Map<
|
||||
string,
|
||||
ProjectResourceTaskFlow
|
||||
>();
|
||||
const emptyReferenceEdgeMap: ReadonlyMap<
|
||||
string,
|
||||
ProjectResourceReferenceEdge
|
||||
> = new Map<string, ProjectResourceReferenceEdge>();
|
||||
|
||||
export const EMPTY_PROJECT_RESOURCE_GRAPH_NEIGHBORS: ProjectResourceGraphNeighbors =
|
||||
{
|
||||
@@ -63,13 +94,19 @@ export const EMPTY_PROJECT_RESOURCE_GRAPH_NEIGHBORS: ProjectResourceGraphNeighbo
|
||||
connectedEdgeIds: emptyStringSet,
|
||||
};
|
||||
|
||||
function stableEdgeId(
|
||||
kind: ProjectResourceReferenceEdge['kind'] | ProjectResourceTaskFlow['kind'],
|
||||
sourceId: string,
|
||||
targetId: string,
|
||||
) {
|
||||
return `${kind}:${JSON.stringify([sourceId, targetId])}`;
|
||||
}
|
||||
export const EMPTY_PROJECT_RESOURCE_GRAPH: ProjectResourceGraph = {
|
||||
resourceIds: emptyStringSet,
|
||||
referenceEdges: [],
|
||||
referenceEdgeById: emptyReferenceEdgeMap,
|
||||
taskFlows: [],
|
||||
taskFlowById: emptyTaskFlowMap,
|
||||
connectionIndex: emptyConnectionMap,
|
||||
producerTaskIdByResourceId: emptyStringMap,
|
||||
unresolvedReferenceResourceIds: [],
|
||||
cyclicResourceIds: emptyStringSet,
|
||||
cyclicTaskIds: emptyStringSet,
|
||||
producerMappingTruncated: false,
|
||||
};
|
||||
|
||||
function uniqueSorted(values: Iterable<string>) {
|
||||
return Array.from(new Set(values)).sort((left, right) =>
|
||||
@@ -77,231 +114,92 @@ function uniqueSorted(values: Iterable<string>) {
|
||||
);
|
||||
}
|
||||
|
||||
function analyzeDirectedCycles(
|
||||
nodeIds: Iterable<string>,
|
||||
edges: readonly DirectedEdge[],
|
||||
): CycleAnalysis {
|
||||
const nodes = new Set(nodeIds);
|
||||
for (const edge of edges) {
|
||||
nodes.add(edge.sourceId);
|
||||
nodes.add(edge.targetId);
|
||||
}
|
||||
const adjacency = new Map<string, string[]>();
|
||||
const reverseAdjacency = new Map<string, string[]>();
|
||||
for (const nodeId of nodes) {
|
||||
adjacency.set(nodeId, []);
|
||||
reverseAdjacency.set(nodeId, []);
|
||||
}
|
||||
for (const edge of edges) {
|
||||
adjacency.get(edge.sourceId)?.push(edge.targetId);
|
||||
reverseAdjacency.get(edge.targetId)?.push(edge.sourceId);
|
||||
}
|
||||
|
||||
const visited = new Set<string>();
|
||||
const finishOrder: string[] = [];
|
||||
for (const root of nodes) {
|
||||
if (visited.has(root)) {
|
||||
continue;
|
||||
}
|
||||
visited.add(root);
|
||||
const stack: Array<{ nodeId: string; nextIndex: number }> = [
|
||||
{ nodeId: root, nextIndex: 0 },
|
||||
];
|
||||
while (stack.length > 0) {
|
||||
const frame = stack[stack.length - 1];
|
||||
if (!frame) {
|
||||
break;
|
||||
}
|
||||
const neighbors = adjacency.get(frame.nodeId) ?? [];
|
||||
const next = neighbors[frame.nextIndex];
|
||||
if (next === undefined) {
|
||||
finishOrder.push(frame.nodeId);
|
||||
stack.pop();
|
||||
continue;
|
||||
}
|
||||
frame.nextIndex += 1;
|
||||
if (!visited.has(next)) {
|
||||
visited.add(next);
|
||||
stack.push({ nodeId: next, nextIndex: 0 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const componentByNode = new Map<string, number>();
|
||||
const componentSizes: number[] = [];
|
||||
for (let index = finishOrder.length - 1; index >= 0; index -= 1) {
|
||||
const root = finishOrder[index];
|
||||
if (root === undefined || componentByNode.has(root)) {
|
||||
continue;
|
||||
}
|
||||
const componentId = componentSizes.length;
|
||||
let size = 0;
|
||||
const stack = [root];
|
||||
componentByNode.set(root, componentId);
|
||||
while (stack.length > 0) {
|
||||
const nodeId = stack.pop();
|
||||
if (nodeId === undefined) {
|
||||
continue;
|
||||
}
|
||||
size += 1;
|
||||
for (const neighbor of reverseAdjacency.get(nodeId) ?? []) {
|
||||
if (!componentByNode.has(neighbor)) {
|
||||
componentByNode.set(neighbor, componentId);
|
||||
stack.push(neighbor);
|
||||
}
|
||||
}
|
||||
}
|
||||
componentSizes.push(size);
|
||||
}
|
||||
|
||||
const cyclicNodeIds = new Set<string>();
|
||||
const cyclicEdgeIds = new Set<string>();
|
||||
for (const edge of edges) {
|
||||
const sourceComponent = componentByNode.get(edge.sourceId);
|
||||
const targetComponent = componentByNode.get(edge.targetId);
|
||||
if (
|
||||
sourceComponent !== undefined &&
|
||||
sourceComponent === targetComponent &&
|
||||
((componentSizes[sourceComponent] ?? 0) > 1 ||
|
||||
edge.sourceId === edge.targetId)
|
||||
) {
|
||||
cyclicNodeIds.add(edge.sourceId);
|
||||
cyclicNodeIds.add(edge.targetId);
|
||||
cyclicEdgeIds.add(edge.id);
|
||||
}
|
||||
}
|
||||
return { cyclicNodeIds, cyclicEdgeIds };
|
||||
}
|
||||
|
||||
export function createProjectResourceGraph(
|
||||
resources: readonly ProjectResourceGraphNodeInput[],
|
||||
tasks: readonly ProjectResourceGraphTaskInput[],
|
||||
export function normalizeProjectResourceGraph(
|
||||
readModel: ProjectResourceGraphReadModel,
|
||||
): ProjectResourceGraph {
|
||||
const resourceById = new Map(
|
||||
resources.map((resource) => [resource.id, resource]),
|
||||
);
|
||||
const resourcesByTask = new Map<string, string[]>();
|
||||
const resourcesByExternalId = new Map<string, string[]>();
|
||||
for (const resource of resources) {
|
||||
if (resource.producerTaskId) {
|
||||
const taskResources = resourcesByTask.get(resource.producerTaskId) ?? [];
|
||||
taskResources.push(resource.id);
|
||||
resourcesByTask.set(resource.producerTaskId, taskResources);
|
||||
}
|
||||
if (resource.externalResourceId) {
|
||||
const externalResources =
|
||||
resourcesByExternalId.get(resource.externalResourceId) ?? [];
|
||||
externalResources.push(resource.id);
|
||||
resourcesByExternalId.set(resource.externalResourceId, externalResources);
|
||||
}
|
||||
}
|
||||
for (const [taskId, resourceIds] of resourcesByTask) {
|
||||
resourcesByTask.set(taskId, uniqueSorted(resourceIds));
|
||||
}
|
||||
|
||||
const unresolvedReferenceResourceIds = new Set<string>();
|
||||
const referenceEdgeById = new Map<string, ProjectResourceReferenceEdge>();
|
||||
for (const target of resources) {
|
||||
for (const externalReferenceId of new Set(target.referenceResourceIds)) {
|
||||
const sourceCandidates =
|
||||
resourcesByExternalId.get(externalReferenceId) ?? [];
|
||||
if (sourceCandidates.length !== 1) {
|
||||
unresolvedReferenceResourceIds.add(externalReferenceId);
|
||||
continue;
|
||||
const resourceIds = new Set(uniqueSorted(readModel.resourceIds));
|
||||
const referenceEdges = readModel.referenceEdges
|
||||
.filter(
|
||||
(edge) =>
|
||||
edge.kind === 'asset-reference' &&
|
||||
resourceIds.has(edge.sourceResourceId) &&
|
||||
resourceIds.has(edge.targetResourceId),
|
||||
)
|
||||
.sort((left, right) => left.id.localeCompare(right.id));
|
||||
const referenceEdgeIds = new Set(referenceEdges.map((edge) => edge.id));
|
||||
const taskFlows = readModel.taskFlows
|
||||
.flatMap((flow) => {
|
||||
if (flow.kind !== 'task-flow') {
|
||||
return [];
|
||||
}
|
||||
const sourceResourceId = sourceCandidates[0];
|
||||
if (sourceResourceId === undefined) {
|
||||
unresolvedReferenceResourceIds.add(externalReferenceId);
|
||||
continue;
|
||||
}
|
||||
if (!resourceById.has(sourceResourceId) || !resourceById.has(target.id)) {
|
||||
continue;
|
||||
}
|
||||
const id = stableEdgeId('asset-reference', sourceResourceId, target.id);
|
||||
referenceEdgeById.set(id, {
|
||||
id,
|
||||
kind: 'asset-reference',
|
||||
sourceResourceId,
|
||||
targetResourceId: target.id,
|
||||
cyclic: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
const referenceEdges = Array.from(referenceEdgeById.values()).sort(
|
||||
(left, right) => left.id.localeCompare(right.id),
|
||||
);
|
||||
const referenceCycles = analyzeDirectedCycles(
|
||||
resourceById.keys(),
|
||||
referenceEdges.map((edge) => ({
|
||||
id: edge.id,
|
||||
sourceId: edge.sourceResourceId,
|
||||
targetId: edge.targetResourceId,
|
||||
})),
|
||||
);
|
||||
for (const edge of referenceEdges) {
|
||||
edge.cyclic = referenceCycles.cyclicEdgeIds.has(edge.id);
|
||||
}
|
||||
|
||||
const taskById = new Map(tasks.map((task) => [task.id, task]));
|
||||
const taskDependencyEdges: DirectedEdge[] = [];
|
||||
for (const targetTask of tasks) {
|
||||
for (const sourceTaskId of new Set(targetTask.dependencies)) {
|
||||
if (!taskById.has(sourceTaskId)) {
|
||||
continue;
|
||||
}
|
||||
taskDependencyEdges.push({
|
||||
id: stableEdgeId('task-flow', sourceTaskId, targetTask.id),
|
||||
sourceId: sourceTaskId,
|
||||
targetId: targetTask.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
const taskCycles = analyzeDirectedCycles(
|
||||
taskById.keys(),
|
||||
taskDependencyEdges,
|
||||
);
|
||||
const taskFlowById = new Map<string, ProjectResourceTaskFlow>();
|
||||
for (const targetTask of tasks) {
|
||||
const targetResourceIds = resourcesByTask.get(targetTask.id) ?? [];
|
||||
if (targetResourceIds.length === 0) {
|
||||
const sourceResourceIds = uniqueSorted(
|
||||
flow.sourceResourceIds.filter((resourceId) =>
|
||||
resourceIds.has(resourceId),
|
||||
),
|
||||
);
|
||||
const targetResourceIds = uniqueSorted(
|
||||
flow.targetResourceIds.filter((resourceId) =>
|
||||
resourceIds.has(resourceId),
|
||||
),
|
||||
);
|
||||
return sourceResourceIds.length > 0 && targetResourceIds.length > 0
|
||||
? [{ ...flow, sourceResourceIds, targetResourceIds }]
|
||||
: [];
|
||||
})
|
||||
.sort((left, right) => left.id.localeCompare(right.id));
|
||||
const taskFlowIds = new Set(taskFlows.map((flow) => flow.id));
|
||||
const connectionIndex = new Map<string, ProjectResourceConnectionIndex>();
|
||||
for (const index of readModel.connectionIndex) {
|
||||
if (!resourceIds.has(index.resourceId)) {
|
||||
continue;
|
||||
}
|
||||
for (const sourceTaskId of new Set(targetTask.dependencies)) {
|
||||
if (!taskById.has(sourceTaskId)) {
|
||||
continue;
|
||||
}
|
||||
const sourceResourceIds = resourcesByTask.get(sourceTaskId) ?? [];
|
||||
if (sourceResourceIds.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const id = stableEdgeId('task-flow', sourceTaskId, targetTask.id);
|
||||
taskFlowById.set(id, {
|
||||
id,
|
||||
kind: 'task-flow',
|
||||
sourceTaskId,
|
||||
targetTaskId: targetTask.id,
|
||||
sourceResourceIds,
|
||||
targetResourceIds,
|
||||
cyclic: false,
|
||||
});
|
||||
}
|
||||
connectionIndex.set(index.resourceId, {
|
||||
upstreamReferenceResourceIds: new Set(
|
||||
index.upstreamReferenceResourceIds.filter((resourceId) =>
|
||||
resourceIds.has(resourceId),
|
||||
),
|
||||
),
|
||||
downstreamReferenceResourceIds: new Set(
|
||||
index.downstreamReferenceResourceIds.filter((resourceId) =>
|
||||
resourceIds.has(resourceId),
|
||||
),
|
||||
),
|
||||
referenceEdgeIds: new Set(
|
||||
index.referenceEdgeIds.filter((edgeId) =>
|
||||
referenceEdgeIds.has(edgeId),
|
||||
),
|
||||
),
|
||||
taskFlowIds: new Set(
|
||||
index.taskFlowIds.filter((flowId) => taskFlowIds.has(flowId)),
|
||||
),
|
||||
});
|
||||
}
|
||||
const taskFlows = Array.from(taskFlowById.values()).sort((left, right) =>
|
||||
left.id.localeCompare(right.id),
|
||||
const producerTaskIdByResourceId = new Map(
|
||||
readModel.producerAssignments.flatMap((assignment) =>
|
||||
resourceIds.has(assignment.resourceId) && assignment.taskId
|
||||
? [[assignment.resourceId, assignment.taskId] as const]
|
||||
: [],
|
||||
),
|
||||
);
|
||||
for (const flow of taskFlows) {
|
||||
flow.cyclic = taskCycles.cyclicEdgeIds.has(flow.id);
|
||||
}
|
||||
|
||||
return {
|
||||
resourceIds: new Set(resourceById.keys()),
|
||||
resourceIds,
|
||||
referenceEdges,
|
||||
referenceEdgeById: new Map(referenceEdges.map((edge) => [edge.id, edge])),
|
||||
taskFlows,
|
||||
taskFlowById: new Map(taskFlows.map((flow) => [flow.id, flow])),
|
||||
connectionIndex,
|
||||
producerTaskIdByResourceId,
|
||||
unresolvedReferenceResourceIds: uniqueSorted(
|
||||
unresolvedReferenceResourceIds,
|
||||
readModel.unresolvedReferenceResourceIds,
|
||||
),
|
||||
cyclicResourceIds: referenceCycles.cyclicNodeIds,
|
||||
cyclicTaskIds: taskCycles.cyclicNodeIds,
|
||||
cyclicResourceIds: new Set(
|
||||
readModel.cyclicResourceIds.filter((resourceId) =>
|
||||
resourceIds.has(resourceId),
|
||||
),
|
||||
),
|
||||
cyclicTaskIds: new Set(readModel.cyclicTaskIds),
|
||||
producerMappingTruncated: Boolean(readModel.producerMappingTruncated),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -312,21 +210,22 @@ export function projectResourceGraphNeighbors(
|
||||
if (!resourceId || !graph.resourceIds.has(resourceId)) {
|
||||
return EMPTY_PROJECT_RESOURCE_GRAPH_NEIGHBORS;
|
||||
}
|
||||
const upstreamResourceIds = new Set<string>();
|
||||
const downstreamResourceIds = new Set<string>();
|
||||
const connectedEdgeIds = new Set<string>();
|
||||
|
||||
for (const edge of graph.referenceEdges) {
|
||||
if (edge.targetResourceId === resourceId) {
|
||||
upstreamResourceIds.add(edge.sourceResourceId);
|
||||
connectedEdgeIds.add(edge.id);
|
||||
}
|
||||
if (edge.sourceResourceId === resourceId) {
|
||||
downstreamResourceIds.add(edge.targetResourceId);
|
||||
connectedEdgeIds.add(edge.id);
|
||||
}
|
||||
const index = graph.connectionIndex.get(resourceId);
|
||||
if (!index) {
|
||||
return EMPTY_PROJECT_RESOURCE_GRAPH_NEIGHBORS;
|
||||
}
|
||||
for (const flow of graph.taskFlows) {
|
||||
const upstreamResourceIds = new Set(
|
||||
index.upstreamReferenceResourceIds,
|
||||
);
|
||||
const downstreamResourceIds = new Set(
|
||||
index.downstreamReferenceResourceIds,
|
||||
);
|
||||
const connectedEdgeIds = new Set(index.referenceEdgeIds);
|
||||
for (const flowId of index.taskFlowIds) {
|
||||
const flow = graph.taskFlowById.get(flowId);
|
||||
if (!flow) {
|
||||
continue;
|
||||
}
|
||||
if (flow.targetResourceIds.includes(resourceId)) {
|
||||
flow.sourceResourceIds.forEach((id) => upstreamResourceIds.add(id));
|
||||
connectedEdgeIds.add(flow.id);
|
||||
|
||||
@@ -5,8 +5,9 @@ import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { ProjectResourceCanvasPosition } from '../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import {
|
||||
createProjectResourceGraph,
|
||||
normalizeProjectResourceGraph,
|
||||
type ProjectResourceGraph,
|
||||
type ProjectResourceGraphReadModel,
|
||||
} from '../src/view/project-development/resourceDependencyGraphModel';
|
||||
import { ResourceDependencyOverlay } from '../src/view/project-development/ResourceDependencyOverlay';
|
||||
|
||||
@@ -25,45 +26,76 @@ function position(
|
||||
}
|
||||
|
||||
function graphFixture() {
|
||||
return createProjectResourceGraph(
|
||||
[
|
||||
const referenceId = 'asset-reference:["source:one","target:one"]';
|
||||
const selfReferenceId = 'asset-reference:["unrelated","unrelated"]';
|
||||
const flowId = 'task-flow:["source-task","target-task"]';
|
||||
const resourceIds = [
|
||||
'source:one',
|
||||
'source:two',
|
||||
'source:three',
|
||||
'target:one',
|
||||
'target:two',
|
||||
'target:three',
|
||||
'unrelated',
|
||||
];
|
||||
const readModel: ProjectResourceGraphReadModel = {
|
||||
resourceIds,
|
||||
referenceEdges: [
|
||||
{
|
||||
id: 'source:one',
|
||||
producerTaskId: 'source-task',
|
||||
externalResourceId: 'external-source',
|
||||
referenceResourceIds: [],
|
||||
id: referenceId,
|
||||
kind: 'asset-reference',
|
||||
sourceResourceId: 'source:one',
|
||||
targetResourceId: 'target:one',
|
||||
cyclic: false,
|
||||
},
|
||||
...['source:two', 'source:three'].map((id) => ({
|
||||
id,
|
||||
producerTaskId: 'source-task',
|
||||
externalResourceId: null,
|
||||
referenceResourceIds: [],
|
||||
})),
|
||||
{
|
||||
id: 'target:one',
|
||||
producerTaskId: 'target-task',
|
||||
externalResourceId: null,
|
||||
referenceResourceIds: ['external-source'],
|
||||
},
|
||||
...['target:two', 'target:three'].map((id) => ({
|
||||
id,
|
||||
producerTaskId: 'target-task',
|
||||
externalResourceId: null,
|
||||
referenceResourceIds: [],
|
||||
})),
|
||||
{
|
||||
id: 'unrelated',
|
||||
producerTaskId: 'unrelated-task',
|
||||
externalResourceId: 'external-unrelated',
|
||||
referenceResourceIds: ['external-unrelated'],
|
||||
id: selfReferenceId,
|
||||
kind: 'asset-reference',
|
||||
sourceResourceId: 'unrelated',
|
||||
targetResourceId: 'unrelated',
|
||||
cyclic: true,
|
||||
},
|
||||
],
|
||||
[
|
||||
{ id: 'source-task', dependencies: [] },
|
||||
{ id: 'target-task', dependencies: ['source-task'] },
|
||||
{ id: 'unrelated-task', dependencies: [] },
|
||||
taskFlows: [
|
||||
{
|
||||
id: flowId,
|
||||
kind: 'task-flow',
|
||||
sourceTaskId: 'source-task',
|
||||
targetTaskId: 'target-task',
|
||||
sourceResourceIds: ['source:one', 'source:two', 'source:three'],
|
||||
targetResourceIds: ['target:one', 'target:two', 'target:three'],
|
||||
cyclic: false,
|
||||
},
|
||||
],
|
||||
);
|
||||
connectionIndex: resourceIds.map((resourceId) => ({
|
||||
resourceId,
|
||||
upstreamReferenceResourceIds:
|
||||
resourceId === 'target:one'
|
||||
? ['source:one']
|
||||
: resourceId === 'unrelated'
|
||||
? ['unrelated']
|
||||
: [],
|
||||
downstreamReferenceResourceIds:
|
||||
resourceId === 'source:one'
|
||||
? ['target:one']
|
||||
: resourceId === 'unrelated'
|
||||
? ['unrelated']
|
||||
: [],
|
||||
referenceEdgeIds:
|
||||
resourceId === 'source:one' || resourceId === 'target:one'
|
||||
? [referenceId]
|
||||
: resourceId === 'unrelated'
|
||||
? [selfReferenceId]
|
||||
: [],
|
||||
taskFlowIds: resourceId === 'unrelated' ? [] : [flowId],
|
||||
})),
|
||||
producerAssignments: [],
|
||||
unresolvedReferenceResourceIds: [],
|
||||
cyclicResourceIds: ['unrelated'],
|
||||
cyclicTaskIds: [],
|
||||
producerMappingTruncated: false,
|
||||
};
|
||||
return normalizeProjectResourceGraph(readModel);
|
||||
}
|
||||
|
||||
function OverlayHarness({
|
||||
@@ -71,11 +103,13 @@ function OverlayHarness({
|
||||
positions,
|
||||
visibleResourceIds,
|
||||
selectedResourceId = null,
|
||||
dragPreview = null,
|
||||
}: {
|
||||
graph: ProjectResourceGraph;
|
||||
positions: ProjectResourceCanvasPosition[];
|
||||
visibleResourceIds: ReadonlySet<string>;
|
||||
selectedResourceId?: string | null;
|
||||
dragPreview?: { resourceId: string; x: number; y: number } | null;
|
||||
}) {
|
||||
return React.createElement(
|
||||
'div',
|
||||
@@ -84,6 +118,7 @@ function OverlayHarness({
|
||||
React.createElement(ResourceDependencyOverlay, {
|
||||
graph,
|
||||
positions,
|
||||
dragPreview,
|
||||
visibleResourceIds,
|
||||
selectedResourceId,
|
||||
}),
|
||||
@@ -95,12 +130,14 @@ function overlayView(
|
||||
positions: ProjectResourceCanvasPosition[],
|
||||
visibleResourceIds: ReadonlySet<string>,
|
||||
selectedResourceId: string | null = null,
|
||||
dragPreview: { resourceId: string; x: number; y: number } | null = null,
|
||||
) {
|
||||
return React.createElement(OverlayHarness, {
|
||||
graph,
|
||||
positions,
|
||||
visibleResourceIds,
|
||||
selectedResourceId,
|
||||
dragPreview,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -159,8 +196,10 @@ describe('ResourceDependencyOverlay', () => {
|
||||
view.rerender(
|
||||
overlayView(
|
||||
graph,
|
||||
[position('source:one', 80, 40), position('target:one', 240, 0)],
|
||||
positions,
|
||||
new Set(['source:one', 'target:one']),
|
||||
null,
|
||||
{ resourceId: 'source:one', x: 80, y: 40 },
|
||||
),
|
||||
);
|
||||
await waitFor(() =>
|
||||
@@ -179,6 +218,56 @@ describe('ResourceDependencyOverlay', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the section origin when updating a dragged path', async () => {
|
||||
const getBoundingClientRect = vi
|
||||
.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
|
||||
.mockImplementation(function () {
|
||||
const isPlane = this.hasAttribute('data-resource-section-plane');
|
||||
return {
|
||||
x: isPlane ? 160 : 20,
|
||||
y: isPlane ? 90 : 10,
|
||||
left: isPlane ? 160 : 20,
|
||||
top: isPlane ? 90 : 10,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
toJSON: () => ({}),
|
||||
};
|
||||
});
|
||||
try {
|
||||
const graph = graphFixture();
|
||||
const positions = [
|
||||
position('source:one', 0, 0),
|
||||
position('target:one', 240, 0),
|
||||
];
|
||||
const visible = new Set(['source:one', 'target:one']);
|
||||
const view = render(overlayView(graph, positions, visible));
|
||||
const overlay = await screen.findByTestId('resource-dependency-overlay');
|
||||
const selector = '[data-edge-kind="asset-reference"]';
|
||||
await waitFor(() =>
|
||||
expect(overlay.querySelector(selector)?.getAttribute('d')).toContain(
|
||||
'M 320 126',
|
||||
),
|
||||
);
|
||||
|
||||
view.rerender(
|
||||
overlayView(graph, positions, visible, null, {
|
||||
resourceId: 'source:one',
|
||||
x: 80,
|
||||
y: 40,
|
||||
}),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(overlay.querySelector(selector)?.getAttribute('d')).toContain(
|
||||
'M 400 166',
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
getBoundingClientRect.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('routes a cyclic self-reference outside the resource card and moves it with the card', async () => {
|
||||
const graph = graphFixture();
|
||||
const view = render(
|
||||
@@ -244,6 +333,76 @@ describe('ResourceDependencyOverlay', () => {
|
||||
expect(unrelated?.classList.contains('is-dimmed')).toBe(true);
|
||||
});
|
||||
|
||||
it('updates only adjacent paths while dragging inside a 4096-resource topology', async () => {
|
||||
const resourceIds = Array.from(
|
||||
{ length: 4096 },
|
||||
(_, index) => `resource:${index}`,
|
||||
);
|
||||
const taskFlows = resourceIds.slice(1).map((resourceId, index) => ({
|
||||
id: `flow:${index}:${index + 1}`,
|
||||
kind: 'task-flow' as const,
|
||||
sourceTaskId: `task:${index}`,
|
||||
targetTaskId: `task:${index + 1}`,
|
||||
sourceResourceIds: [`resource:${index}`],
|
||||
targetResourceIds: [resourceId],
|
||||
cyclic: false,
|
||||
}));
|
||||
const graph = normalizeProjectResourceGraph({
|
||||
resourceIds,
|
||||
referenceEdges: [],
|
||||
taskFlows,
|
||||
connectionIndex: resourceIds.map((resourceId, index) => ({
|
||||
resourceId,
|
||||
upstreamReferenceResourceIds: [],
|
||||
downstreamReferenceResourceIds: [],
|
||||
referenceEdgeIds: [],
|
||||
taskFlowIds: [
|
||||
...(index > 0 ? [`flow:${index - 1}:${index}`] : []),
|
||||
...(index < resourceIds.length - 1
|
||||
? [`flow:${index}:${index + 1}`]
|
||||
: []),
|
||||
],
|
||||
})),
|
||||
producerAssignments: [],
|
||||
unresolvedReferenceResourceIds: [],
|
||||
cyclicResourceIds: [],
|
||||
cyclicTaskIds: [],
|
||||
producerMappingTruncated: false,
|
||||
});
|
||||
const positions = [
|
||||
position('resource:2047', 0, 0),
|
||||
position('resource:2048', 220, 0),
|
||||
position('resource:2049', 440, 0),
|
||||
];
|
||||
const visible = new Set(positions.map(({ resourceId }) => resourceId));
|
||||
const view = render(overlayView(graph, positions, visible));
|
||||
const overlay = await screen.findByTestId('resource-dependency-overlay');
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
overlay.querySelectorAll('[data-edge-kind="task-flow"]'),
|
||||
).toHaveLength(2),
|
||||
);
|
||||
const setAttribute = vi.spyOn(
|
||||
SVGElement.prototype,
|
||||
'setAttribute',
|
||||
);
|
||||
try {
|
||||
view.rerender(
|
||||
overlayView(graph, positions, visible, null, {
|
||||
resourceId: 'resource:2048',
|
||||
x: 260,
|
||||
y: 32,
|
||||
}),
|
||||
);
|
||||
const geometryUpdates = setAttribute.mock.calls.filter(
|
||||
([name]) => name === 'd',
|
||||
);
|
||||
expect(geometryUpdates).toHaveLength(6);
|
||||
} finally {
|
||||
setAttribute.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('disconnects layout observers when the SVG layer is destroyed', () => {
|
||||
const observe = vi.fn();
|
||||
const disconnect = vi.fn();
|
||||
@@ -257,16 +416,27 @@ describe('ResourceDependencyOverlay', () => {
|
||||
vi.stubGlobal('ResizeObserver', TestResizeObserver);
|
||||
try {
|
||||
const graph = graphFixture();
|
||||
const positions = Array.from(graph.resourceIds).map(
|
||||
(resourceId, index) => position(resourceId, index * 200, 0),
|
||||
);
|
||||
const view = render(
|
||||
overlayView(
|
||||
graph,
|
||||
Array.from(graph.resourceIds).map((resourceId, index) =>
|
||||
position(resourceId, index * 200, 0),
|
||||
),
|
||||
positions,
|
||||
new Set(graph.resourceIds),
|
||||
),
|
||||
);
|
||||
|
||||
expect(observe).toHaveBeenCalledTimes(2);
|
||||
view.rerender(
|
||||
overlayView(
|
||||
graph,
|
||||
positions,
|
||||
new Set(graph.resourceIds),
|
||||
null,
|
||||
{ resourceId: 'source:one', x: 32, y: 24 },
|
||||
),
|
||||
);
|
||||
expect(observe).toHaveBeenCalledTimes(2);
|
||||
view.unmount();
|
||||
expect(disconnect).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -732,7 +732,7 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
localPath: 'assets/spec-source.json',
|
||||
source: {
|
||||
kind: 'canvas',
|
||||
taskId: 'art-director',
|
||||
taskId: 'task-1',
|
||||
resourceId: 'canvas-spec-source',
|
||||
},
|
||||
},
|
||||
@@ -743,7 +743,7 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
localPath: 'assets/ui-dependency.json',
|
||||
source: {
|
||||
kind: 'canvas',
|
||||
taskId: 'design-foundation',
|
||||
taskId: 'task-2',
|
||||
resourceId: 'canvas-ui-target',
|
||||
referenceResourceIds: ['canvas-spec-source'],
|
||||
},
|
||||
@@ -761,6 +761,130 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
},
|
||||
);
|
||||
|
||||
const referenceId =
|
||||
'asset-reference:["asset:dependency-spec","asset:dependency-ui"]';
|
||||
const selfReferenceId =
|
||||
'asset-reference:["asset:unrelated-cycle","asset:unrelated-cycle"]';
|
||||
const flowId =
|
||||
'task-flow:["art-director","design-foundation"]';
|
||||
let layoutRevision = 0;
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === 'read_local_project_resource_graph') {
|
||||
expect(args?.resources).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
resourceId: 'asset:dependency-spec',
|
||||
manifestAssetId: 'dependency-spec',
|
||||
producerTaskId: null,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
return {
|
||||
resourceIds: [
|
||||
'asset:dependency-spec',
|
||||
'asset:dependency-ui',
|
||||
'asset:unrelated-cycle',
|
||||
],
|
||||
referenceEdges: [
|
||||
{
|
||||
id: referenceId,
|
||||
kind: 'asset-reference',
|
||||
sourceResourceId: 'asset:dependency-spec',
|
||||
targetResourceId: 'asset:dependency-ui',
|
||||
cyclic: false,
|
||||
},
|
||||
{
|
||||
id: selfReferenceId,
|
||||
kind: 'asset-reference',
|
||||
sourceResourceId: 'asset:unrelated-cycle',
|
||||
targetResourceId: 'asset:unrelated-cycle',
|
||||
cyclic: true,
|
||||
},
|
||||
],
|
||||
taskFlows: [
|
||||
{
|
||||
id: flowId,
|
||||
kind: 'task-flow',
|
||||
sourceTaskId: 'art-director',
|
||||
targetTaskId: 'design-foundation',
|
||||
sourceResourceIds: ['asset:dependency-spec'],
|
||||
targetResourceIds: ['asset:dependency-ui'],
|
||||
cyclic: false,
|
||||
},
|
||||
],
|
||||
connectionIndex: [
|
||||
{
|
||||
resourceId: 'asset:dependency-spec',
|
||||
upstreamReferenceResourceIds: [],
|
||||
downstreamReferenceResourceIds: ['asset:dependency-ui'],
|
||||
referenceEdgeIds: [referenceId],
|
||||
taskFlowIds: [flowId],
|
||||
},
|
||||
{
|
||||
resourceId: 'asset:dependency-ui',
|
||||
upstreamReferenceResourceIds: ['asset:dependency-spec'],
|
||||
downstreamReferenceResourceIds: [],
|
||||
referenceEdgeIds: [referenceId],
|
||||
taskFlowIds: [flowId],
|
||||
},
|
||||
{
|
||||
resourceId: 'asset:unrelated-cycle',
|
||||
upstreamReferenceResourceIds: [
|
||||
'asset:unrelated-cycle',
|
||||
],
|
||||
downstreamReferenceResourceIds: [
|
||||
'asset:unrelated-cycle',
|
||||
],
|
||||
referenceEdgeIds: [selfReferenceId],
|
||||
taskFlowIds: [],
|
||||
},
|
||||
],
|
||||
producerAssignments: [
|
||||
{
|
||||
resourceId: 'asset:dependency-spec',
|
||||
taskId: 'art-director',
|
||||
},
|
||||
{
|
||||
resourceId: 'asset:dependency-ui',
|
||||
taskId: 'design-foundation',
|
||||
},
|
||||
],
|
||||
unresolvedReferenceResourceIds: [],
|
||||
cyclicResourceIds: ['asset:unrelated-cycle'],
|
||||
cyclicTaskIds: [],
|
||||
producerMappingTruncated: false,
|
||||
};
|
||||
}
|
||||
if (command === 'read_local_project_resource_canvas_layout') {
|
||||
return {
|
||||
schemaVersion: 'game-creator-resource-layout.v1',
|
||||
projectId: 'workbench-resource-graph',
|
||||
mode: args?.mode,
|
||||
revision: layoutRevision,
|
||||
positions: [],
|
||||
updatedAt: 0,
|
||||
};
|
||||
}
|
||||
if (command === 'update_local_project_resource_canvas_layout') {
|
||||
layoutRevision += 1;
|
||||
return {
|
||||
status: 'updated',
|
||||
layout: {
|
||||
schemaVersion: 'game-creator-resource-layout.v1',
|
||||
projectId: 'workbench-resource-graph',
|
||||
mode: args?.mode,
|
||||
revision: layoutRevision,
|
||||
positions: args?.positions,
|
||||
updatedAt: layoutRevision,
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected invoke ${command}`);
|
||||
},
|
||||
);
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
|
||||
const view = render(
|
||||
React.createElement(ProjectDevelopmentView, {
|
||||
projectName: '资源依赖图测试',
|
||||
|
||||
@@ -1,157 +1,156 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
createProjectResourceGraph,
|
||||
normalizeProjectResourceGraph,
|
||||
projectResourceGraphNeighbors,
|
||||
type ProjectResourceGraphNodeInput,
|
||||
type ProjectResourceGraphReadModel,
|
||||
} from '../src/view/project-development/resourceDependencyGraphModel';
|
||||
|
||||
function resource(
|
||||
id: string,
|
||||
producerTaskId: string | null,
|
||||
options: Partial<ProjectResourceGraphNodeInput> = {},
|
||||
): ProjectResourceGraphNodeInput {
|
||||
function readModel(
|
||||
overrides: Partial<ProjectResourceGraphReadModel> = {},
|
||||
): ProjectResourceGraphReadModel {
|
||||
return {
|
||||
id,
|
||||
producerTaskId,
|
||||
externalResourceId: null,
|
||||
referenceResourceIds: [],
|
||||
...options,
|
||||
resourceIds: [],
|
||||
referenceEdges: [],
|
||||
taskFlows: [],
|
||||
connectionIndex: [],
|
||||
producerAssignments: [],
|
||||
unresolvedReferenceResourceIds: [],
|
||||
cyclicResourceIds: [],
|
||||
cyclicTaskIds: [],
|
||||
producerMappingTruncated: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('resource dependency graph model', () => {
|
||||
it('maps exact external asset references, deduplicates them, and filters missing resources', () => {
|
||||
const graph = createProjectResourceGraph(
|
||||
[
|
||||
resource('asset:spec', 'art-director', {
|
||||
externalResourceId: 'canvas-resource-spec',
|
||||
}),
|
||||
resource('asset:ui', 'design-foundation', {
|
||||
externalResourceId: 'canvas-resource-ui',
|
||||
referenceResourceIds: [
|
||||
'canvas-resource-spec',
|
||||
'canvas-resource-spec',
|
||||
'missing-resource',
|
||||
],
|
||||
}),
|
||||
],
|
||||
[
|
||||
{ id: 'art-director', dependencies: [] },
|
||||
{ id: 'design-foundation', dependencies: ['art-director'] },
|
||||
],
|
||||
it('normalizes the Rust read model and filters stale resource endpoints', () => {
|
||||
const graph = normalizeProjectResourceGraph(
|
||||
readModel({
|
||||
resourceIds: ['asset:source', 'asset:target'],
|
||||
referenceEdges: [
|
||||
{
|
||||
id: 'valid-reference',
|
||||
kind: 'asset-reference',
|
||||
sourceResourceId: 'asset:source',
|
||||
targetResourceId: 'asset:target',
|
||||
cyclic: false,
|
||||
},
|
||||
{
|
||||
id: 'ghost-reference',
|
||||
kind: 'asset-reference',
|
||||
sourceResourceId: 'asset:deleted',
|
||||
targetResourceId: 'asset:target',
|
||||
cyclic: false,
|
||||
},
|
||||
],
|
||||
taskFlows: [
|
||||
{
|
||||
id: 'valid-flow',
|
||||
kind: 'task-flow',
|
||||
sourceTaskId: 'source-task',
|
||||
targetTaskId: 'target-task',
|
||||
sourceResourceIds: ['asset:source', 'asset:deleted'],
|
||||
targetResourceIds: ['asset:target'],
|
||||
cyclic: false,
|
||||
},
|
||||
{
|
||||
id: 'ghost-flow',
|
||||
kind: 'task-flow',
|
||||
sourceTaskId: 'deleted-task',
|
||||
targetTaskId: 'target-task',
|
||||
sourceResourceIds: ['asset:deleted'],
|
||||
targetResourceIds: ['asset:target'],
|
||||
cyclic: false,
|
||||
},
|
||||
],
|
||||
cyclicResourceIds: ['asset:source', 'asset:deleted'],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(graph.referenceEdges).toEqual([
|
||||
expect(graph.referenceEdges.map((edge) => edge.id)).toEqual([
|
||||
'valid-reference',
|
||||
]);
|
||||
expect(graph.taskFlows).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: 'asset-reference',
|
||||
sourceResourceId: 'asset:spec',
|
||||
targetResourceId: 'asset:ui',
|
||||
cyclic: false,
|
||||
id: 'valid-flow',
|
||||
sourceResourceIds: ['asset:source'],
|
||||
targetResourceIds: ['asset:target'],
|
||||
}),
|
||||
]);
|
||||
expect(graph.unresolvedReferenceResourceIds).toEqual(['missing-resource']);
|
||||
expect(graph.cyclicResourceIds).toEqual(new Set(['asset:source']));
|
||||
});
|
||||
|
||||
it('aggregates each task dependency into one flow instead of a resource cartesian product', () => {
|
||||
const graph = createProjectResourceGraph(
|
||||
[
|
||||
resource('upstream:a', 'task-a'),
|
||||
resource('upstream:b', 'task-a'),
|
||||
resource('downstream:a', 'task-b'),
|
||||
resource('downstream:b', 'task-b'),
|
||||
resource('downstream:c', 'task-b'),
|
||||
],
|
||||
[
|
||||
{ id: 'task-a', dependencies: [] },
|
||||
{ id: 'task-b', dependencies: ['task-a', 'task-a'] },
|
||||
],
|
||||
it('queries direct reference and aggregated task-flow neighbors from the bounded index', () => {
|
||||
const graph = normalizeProjectResourceGraph(
|
||||
readModel({
|
||||
resourceIds: ['source:a', 'source:b', 'target:a', 'target:b'],
|
||||
referenceEdges: [
|
||||
{
|
||||
id: 'reference:a',
|
||||
kind: 'asset-reference',
|
||||
sourceResourceId: 'source:a',
|
||||
targetResourceId: 'target:a',
|
||||
cyclic: false,
|
||||
},
|
||||
],
|
||||
taskFlows: [
|
||||
{
|
||||
id: 'flow:a-b',
|
||||
kind: 'task-flow',
|
||||
sourceTaskId: 'task:a',
|
||||
targetTaskId: 'task:b',
|
||||
sourceResourceIds: ['source:a', 'source:b'],
|
||||
targetResourceIds: ['target:a', 'target:b'],
|
||||
cyclic: false,
|
||||
},
|
||||
],
|
||||
connectionIndex: [
|
||||
{
|
||||
resourceId: 'target:a',
|
||||
upstreamReferenceResourceIds: ['source:a'],
|
||||
downstreamReferenceResourceIds: [],
|
||||
referenceEdgeIds: ['reference:a'],
|
||||
taskFlowIds: ['flow:a-b'],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(graph.taskFlows).toHaveLength(1);
|
||||
expect(graph.taskFlows[0]).toMatchObject({
|
||||
sourceTaskId: 'task-a',
|
||||
targetTaskId: 'task-b',
|
||||
sourceResourceIds: ['upstream:a', 'upstream:b'],
|
||||
targetResourceIds: ['downstream:a', 'downstream:b', 'downstream:c'],
|
||||
});
|
||||
});
|
||||
|
||||
it('detects resource and task dependency cycles without recursive traversal', () => {
|
||||
const graph = createProjectResourceGraph(
|
||||
[
|
||||
resource('asset:a', 'task-a', {
|
||||
externalResourceId: 'external-a',
|
||||
referenceResourceIds: ['external-b'],
|
||||
}),
|
||||
resource('asset:b', 'task-b', {
|
||||
externalResourceId: 'external-b',
|
||||
referenceResourceIds: ['external-a'],
|
||||
}),
|
||||
],
|
||||
[
|
||||
{ id: 'task-a', dependencies: ['task-b'] },
|
||||
{ id: 'task-b', dependencies: ['task-a'] },
|
||||
],
|
||||
);
|
||||
|
||||
expect(graph.cyclicResourceIds).toEqual(new Set(['asset:a', 'asset:b']));
|
||||
expect(graph.referenceEdges.every((edge) => edge.cyclic)).toBe(true);
|
||||
expect(graph.cyclicTaskIds).toEqual(new Set(['task-a', 'task-b']));
|
||||
expect(graph.taskFlows.every((flow) => flow.cyclic)).toBe(true);
|
||||
});
|
||||
|
||||
it('detects task cycles even when a cyclic task has no resource card', () => {
|
||||
const graph = createProjectResourceGraph(
|
||||
[resource('asset:a', 'task-a')],
|
||||
[
|
||||
{ id: 'task-a', dependencies: ['task-b'] },
|
||||
{ id: 'task-b', dependencies: ['task-a'] },
|
||||
],
|
||||
);
|
||||
|
||||
expect(graph.taskFlows).toHaveLength(0);
|
||||
expect(graph.cyclicTaskIds).toEqual(new Set(['task-a', 'task-b']));
|
||||
});
|
||||
|
||||
it('returns direct upstream and downstream resources for exact and aggregated edges', () => {
|
||||
const graph = createProjectResourceGraph(
|
||||
[
|
||||
resource('task-a:one', 'task-a', {
|
||||
externalResourceId: 'external-a',
|
||||
}),
|
||||
resource('task-a:two', 'task-a'),
|
||||
resource('task-b:one', 'task-b', {
|
||||
referenceResourceIds: ['external-a'],
|
||||
}),
|
||||
],
|
||||
[
|
||||
{ id: 'task-a', dependencies: [] },
|
||||
{ id: 'task-b', dependencies: ['task-a'] },
|
||||
],
|
||||
);
|
||||
|
||||
const neighbors = projectResourceGraphNeighbors(graph, 'task-b:one');
|
||||
const neighbors = projectResourceGraphNeighbors(graph, 'target:a');
|
||||
expect(neighbors.upstreamResourceIds).toEqual(
|
||||
new Set(['task-a:one', 'task-a:two']),
|
||||
new Set(['source:a', 'source:b']),
|
||||
);
|
||||
expect(neighbors.downstreamResourceIds).toEqual(new Set());
|
||||
expect(neighbors.connectedEdgeIds.size).toBe(2);
|
||||
expect(neighbors.connectedEdgeIds).toEqual(
|
||||
new Set(['reference:a', 'flow:a-b']),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps graph construction linear for the supported resource bound', () => {
|
||||
const resources = Array.from({ length: 4096 }, (_, index) =>
|
||||
resource(`resource:${index}`, `task:${index}`),
|
||||
it('keeps real producer assignments and audit truncation metadata', () => {
|
||||
const graph = normalizeProjectResourceGraph(
|
||||
readModel({
|
||||
resourceIds: ['asset:spec', 'asset:ui'],
|
||||
producerAssignments: [
|
||||
{ resourceId: 'asset:spec', taskId: 'art-director' },
|
||||
{ resourceId: 'asset:ui', taskId: 'design-foundation' },
|
||||
{ resourceId: 'asset:deleted', taskId: 'task-1' },
|
||||
],
|
||||
producerMappingTruncated: true,
|
||||
}),
|
||||
);
|
||||
const tasks = Array.from({ length: 4096 }, (_, index) => ({
|
||||
id: `task:${index}`,
|
||||
dependencies: index === 0 ? [] : [`task:${index - 1}`],
|
||||
}));
|
||||
const startedAt = performance.now();
|
||||
const graph = createProjectResourceGraph(resources, tasks);
|
||||
const elapsedMs = performance.now() - startedAt;
|
||||
|
||||
expect(graph.taskFlows).toHaveLength(4095);
|
||||
expect(elapsedMs).toBeLessThan(2000);
|
||||
expect(graph.producerTaskIdByResourceId).toEqual(
|
||||
new Map([
|
||||
['asset:spec', 'art-director'],
|
||||
['asset:ui', 'design-foundation'],
|
||||
]),
|
||||
);
|
||||
expect(graph.producerMappingTruncated).toBe(true);
|
||||
expect(projectResourceGraphNeighbors(graph, 'asset:deleted')).toEqual({
|
||||
upstreamResourceIds: new Set(),
|
||||
downstreamResourceIds: new Set(),
|
||||
connectedEdgeIds: new Set(),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user