test(web): add one-pass feature smoke plan and record blockers
This commit is contained in:
@@ -27,7 +27,7 @@ import { coreSceneCommands } from "../../../protocol/core-scene-commands";
|
||||
import { meshAuthoringCommands } from "../../../protocol/mesh-authoring-commands";
|
||||
import { nonMeshGeometryCommands } from "../../../protocol/nonmesh-geometry-commands";
|
||||
import { animationRenderCommands } from "../../../protocol/animation-render-commands";
|
||||
import { ioMediaCommands } from "../../../protocol/io-media-commands";
|
||||
import { ioMediaCommands, type IOSceneFormat } from "../../../protocol/io-media-commands";
|
||||
import { isWorkbenchCommandAvailable } from "../../../protocol/workbench-command-groups";
|
||||
import { shaderGraphCommands } from "../../../protocol/shader-graph-commands";
|
||||
import { renderCompositorCommands } from "../../../protocol/render-compositor-commands";
|
||||
@@ -122,6 +122,14 @@ function recentProjectIssueMessage(code: RecentProjectIssue["code"]): string {
|
||||
return "项目元数据不一致";
|
||||
}
|
||||
|
||||
function encodeBase64(buffer: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = "";
|
||||
const chunkSize = 0x8000;
|
||||
for (let offset = 0; offset < bytes.length; offset += chunkSize) binary += String.fromCharCode(...bytes.subarray(offset, Math.min(offset + chunkSize, bytes.length)));
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function StorageBudgetPanel({ budget, onCleanup }: { budget: StorageBudgetResult | null; onCleanup: () => void }) {
|
||||
const values = budget ? [
|
||||
["项目", budget.projectBytes],
|
||||
@@ -2715,7 +2723,8 @@ export function App() {
|
||||
return;
|
||||
}
|
||||
if (event.key === "F3") {
|
||||
if (interactiveTarget && target?.matches("input, textarea, select, [contenteditable='true']")) return;
|
||||
const textEntryTarget = target?.matches("textarea, select, [contenteditable='true'], input:not([type='file'])");
|
||||
if (interactiveTarget && textEntryTarget) return;
|
||||
event.preventDefault();
|
||||
dispatchUI({ type: "toggleOperatorSearch", open: true });
|
||||
return;
|
||||
@@ -2969,6 +2978,56 @@ export function App() {
|
||||
return gpuAssets;
|
||||
};
|
||||
|
||||
const importSceneFile = async (file: File, format: IOSceneFormat): Promise<void> => {
|
||||
const identity = nextUserActionIdentity("OPEN");
|
||||
if (!acquireProjectActionLock(identity)) return;
|
||||
startUserAction(identity);
|
||||
const controller = new AbortController();
|
||||
openAbortControllerRef.current = controller;
|
||||
const client = webClientRef.current;
|
||||
try {
|
||||
if (!client) {
|
||||
failUserAction(identity, "OPEN_ENGINE_UNAVAILABLE");
|
||||
return;
|
||||
}
|
||||
const input = await readFileBytes(file, { signal: controller.signal });
|
||||
const inputSha256 = await sha256Hex(input);
|
||||
const result = await client.importScene(format, file.name, file.type || "application/octet-stream", encodeBase64(input));
|
||||
const projectId = file.name.replace(/\.[^.]+$/i, "").replace(/[^A-Za-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64) || "untitled";
|
||||
projectIdRef.current = projectId;
|
||||
projectDisplayNameRef.current = file.name;
|
||||
rememberLastProjectId(projectId);
|
||||
projectRevisionRef.current = result.snapshot.revision;
|
||||
committedProjectRevisionRef.current = 0;
|
||||
committedProjectHashRef.current = inputSha256;
|
||||
setSaveTransaction(createSaveTransactionState({ revision: 0, sha256: inputSha256 }));
|
||||
setDirtyState(createDirtyState(result.snapshot.revision));
|
||||
setVolumeProject({ projectId, sourceBlendSha256: inputSha256 });
|
||||
setPreview(null);
|
||||
setLodLevels(null);
|
||||
setGPUTextureAssets([]);
|
||||
setSnapshot(result.snapshot);
|
||||
setSelectedObjectIds(new Set(result.snapshot.activeObjectId ? [result.snapshot.activeObjectId] : []));
|
||||
setGeometryBuffers(result.geometryBuffers);
|
||||
setNonMeshGeometryBuffers(result.nonMeshGeometryBuffers ?? []);
|
||||
setFrame(result.snapshot.frame.current);
|
||||
setEngineStatus(`Engine: ${format} imported r${result.snapshot.revision} (${result.snapshot.nodes.length} objects)`);
|
||||
void cachePackedAssets(projectId, result.snapshot).then((assets) => {
|
||||
if (projectIdRef.current === projectId) setGPUTextureAssets(assets);
|
||||
});
|
||||
void restoreCachedLODs(projectId, result.snapshot);
|
||||
succeedUserAction(identity);
|
||||
}
|
||||
catch (error) {
|
||||
failUserAction(identity, error instanceof FileByteReadError ? error.code : "IO_IMPORT_FAILED");
|
||||
setEngineStatus(recordDiagnostic("ENGINE", "BLEND_OPEN_FAILED", error, { fileName: file.name, format }));
|
||||
}
|
||||
finally {
|
||||
if (openAbortControllerRef.current === controller) openAbortControllerRef.current = null;
|
||||
releaseProjectActionLock(identity);
|
||||
}
|
||||
};
|
||||
|
||||
const openBlendFile = async (file: File): Promise<void> => {
|
||||
const identity = nextUserActionIdentity("OPEN");
|
||||
if (!acquireProjectActionLock(identity)) return;
|
||||
@@ -3497,7 +3556,7 @@ export function App() {
|
||||
{(["Layout", "Modeling", "Animation"] as WorkspaceId[]).map((item) => <button key={item} className={item === workspace ? "workspace-tab active" : "workspace-tab"} type="button" aria-current={item === workspace ? "page" : undefined} onClick={() => dispatchUI({ type: "switchWorkspace", workspaceId: item })}>{item}</button>)}
|
||||
</nav>
|
||||
<div className="topbar-actions"><select aria-label="最近项目" data-testid="recent-projects" defaultValue="" onChange={(event) => { const projectId = event.target.value; const displayName = event.target.selectedOptions[0]?.textContent ?? projectId; if (!projectId) return; void recoverCachedProject(projectId, displayName); event.currentTarget.value = ""; }}><option value="">最近项目 ({recentProjects.length})</option>{recentProjects.map((project) => <option key={project.projectId} value={project.projectId}>{project.displayName}</option>)}</select><button type="button" aria-label="打开 .blend" onClick={() => fileInputRef.current?.click()}>打开</button><button type="button" aria-label="关闭项目" disabled={!snapshot} onClick={closeProject}>关闭</button><button type="button" aria-label="恢复项目" onClick={() => void recoverCachedProject()}>恢复</button><button type="button" aria-label="保存项目" onClick={() => void saveBlend()}>保存</button><button type="button" aria-label="导出 GLB" onClick={() => void reportGLBExport()}>GLB</button><button type="button" aria-label="导出诊断报告" data-testid="export-diagnostics" disabled={diagnostics.length === 0} onClick={exportDiagnosticReport}>诊断</button><button type="button" aria-label="撤销" onClick={() => void applyEditCommand({ type: "undo" })}>↶</button><button type="button" aria-label="重做" onClick={() => void applyEditCommand({ type: "redo" })}>↷</button><button ref={operatorSearchTriggerRef} type="button" aria-label="操作搜索" aria-haspopup="dialog" onClick={() => dispatchUI({ type: "toggleOperatorSearch", open: true })}>F3</button>{workerFaultTestMode ? <button type="button" data-testid="inject-worker-crash" aria-label="注入 Worker 崩溃" onClick={injectWorkerCrash}>Fault</button> : null}</div>
|
||||
<input ref={fileInputRef} className="file-input-hidden" type="file" accept={ioFormatUIAccept(IO_FORMAT_UI_REGISTRY)} aria-label="打开 .blend 文件" data-testid="blend-file-input" data-io-format-import-routes={IO_FORMAT_UI_REGISTRY.importRoutes.map((route) => route.format).join(",")} onChange={(event) => { const file = event.target.files?.[0]; if (file) { const gate = gateIOFormatFileSelection(file.name, IO_FORMAT_UI_REGISTRY); if (gate.status === "BLOCKED") setEngineStatus(recordDiagnostic("ENGINE", "IO_FORMAT_UNSUPPORTED", gate, { fileName: file.name })); else void openBlendFile(file); } event.target.value = ""; }} />
|
||||
<input ref={fileInputRef} className="file-input-hidden" type="file" accept={ioFormatUIAccept(IO_FORMAT_UI_REGISTRY)} aria-label="打开 .blend 文件" data-testid="blend-file-input" data-io-format-import-routes={IO_FORMAT_UI_REGISTRY.importRoutes.map((route) => route.format).join(",")} onChange={(event) => { const file = event.target.files?.[0]; if (file) { const gate = gateIOFormatFileSelection(file.name, IO_FORMAT_UI_REGISTRY); if (gate.status === "BLOCKED") setEngineStatus(recordDiagnostic("ENGINE", "IO_FORMAT_UNSUPPORTED", gate, { fileName: file.name })); else if (file.name.toLowerCase().endsWith(".blend")) void openBlendFile(file); else { const route = IO_FORMAT_UI_REGISTRY.importRoutes.find((candidate) => candidate.execution === "LOCAL" && candidate.extensions.some((extension) => file.name.toLowerCase().endsWith(extension))); if (route) void importSceneFile(file, route.format); else setEngineStatus(recordDiagnostic("ENGINE", "IO_FORMAT_UNSUPPORTED", gate, { fileName: file.name })); } } event.target.value = ""; }} />
|
||||
</header>
|
||||
{workerFault ? <div className="worker-fault-banner" role="alert" data-testid="worker-fault-banner"><span>{workerFault.source === "engine" ? "Engine Worker" : "Storage Worker"} stopped; current project list and scene are retained.</span><button type="button" data-testid="restart-and-recover" onClick={() => void restartWorkersAndRecover()} disabled={workerRecoveryStatus === "RUNNING"}>{workerRecoveryStatus === "RUNNING" ? "恢复中..." : "重启并恢复"}</button>{workerRecoveryStatus === "FAILED" ? <span data-testid="worker-recovery-error">Recovery failed; retry is safe.</span> : null}</div> : null}
|
||||
{recentProjectIssues.length > 0 ? <div className="recent-project-repair-banner" role="alert" data-testid="recent-project-repair-banner"><span>最近项目中有 {recentProjectIssues.length} 个条目无法验证;当前场景不会被删除。</span><div className="recent-project-repair-list">{recentProjectIssues.map((issue) => <div key={issue.project.projectId} className="recent-project-repair-item" data-project-id={issue.project.projectId} data-issue-code={issue.code}><span>{issue.project.displayName}: {recentProjectIssueMessage(issue.code)}</span><button type="button" data-testid={`remove-recent-project-${issue.project.projectId}`} aria-label={`移除失效项目引用 ${issue.project.displayName}`} onClick={() => void removeInvalidRecentProject(issue.project.projectId)}>移除引用</button></div>)}</div></div> : null}
|
||||
|
||||
@@ -245,6 +245,21 @@ export class WebEngineClient {
|
||||
return { status: result.status, snapshot: result.snapshot, geometryBuffers: [...this.geometryBuffers], nonMeshGeometryBuffers: [...this.nonMeshGeometryBuffers] };
|
||||
}
|
||||
|
||||
async importScene(format: string, fileName: string, mimeType: string, base64: string): Promise<BlendOpenResult & { delta: SceneDelta; importResult?: unknown }> {
|
||||
const result = await this.request({ type: "jsonCommand", cmd: "importScene", params: { format, fileName, mimeType, base64 } });
|
||||
if (!result.snapshot || !result.delta) throw this.report("INVALID_ARGUMENT", "IO 导入未返回场景结果", true);
|
||||
this.geometryBuffers = result.geometryBuffers ?? this.geometryBuffers;
|
||||
this.nonMeshGeometryBuffers = result.nonMeshGeometryBuffers ?? this.nonMeshGeometryBuffers;
|
||||
return {
|
||||
status: result.status,
|
||||
snapshot: result.snapshot,
|
||||
geometryBuffers: [...this.geometryBuffers],
|
||||
nonMeshGeometryBuffers: [...this.nonMeshGeometryBuffers],
|
||||
delta: result.delta,
|
||||
importResult: result.json,
|
||||
};
|
||||
}
|
||||
|
||||
async snapshot(): Promise<BlendOpenResult> {
|
||||
const result = await this.request({ type: "snapshot" });
|
||||
if (!result.snapshot) throw this.report("INVALID_ARGUMENT", "WebEngine 未返回 SceneIR", true);
|
||||
|
||||
Reference in New Issue
Block a user