收紧生成任务轮询与资产读取路径
空生成任务列表不再保持四秒轮询 读取资源字节优先通过read-url直连OSS 保留read-bytes作为OSS读取失败兜底 更新资产导出与项目记忆文档口径
This commit is contained in:
@@ -4,6 +4,7 @@ import { createMiniGameDraftGenerationState } from '../../services/miniGameDraft
|
||||
import {
|
||||
buildExternalGenerationQueuePresentation,
|
||||
buildExternalGenerationQueueStatus,
|
||||
shouldUseFastExternalGenerationTaskPolling,
|
||||
} from './platformExternalGenerationQueueStatusModel';
|
||||
import {
|
||||
resolveFinishedMiniGameDraftGenerationState,
|
||||
@@ -129,4 +130,50 @@ describe('buildExternalGenerationQueueStatus', () => {
|
||||
}).shouldShow,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('只在队列活跃或存在未确认终态任务时使用快速轮询', () => {
|
||||
expect(
|
||||
shouldUseFastExternalGenerationTaskPolling({
|
||||
pendingCount: 0,
|
||||
runningCount: 0,
|
||||
unacknowledgedTerminalCount: 0,
|
||||
tasks: [],
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
shouldUseFastExternalGenerationTaskPolling({
|
||||
currentStatus: 'queued',
|
||||
pendingCount: 0,
|
||||
runningCount: 0,
|
||||
unacknowledgedTerminalCount: 0,
|
||||
tasks: [],
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
shouldUseFastExternalGenerationTaskPolling({
|
||||
pendingCount: 0,
|
||||
runningCount: 0,
|
||||
unacknowledgedTerminalCount: 0,
|
||||
tasks: [
|
||||
{
|
||||
jobId: 'extgen-completed',
|
||||
jobKind: 'editor_image_generation',
|
||||
sourceModule: 'editor',
|
||||
sourceEntityId: 'project-1',
|
||||
requestLabel: '图片生成',
|
||||
status: 'completed',
|
||||
phaseLabel: '已完成',
|
||||
phaseDetail: '已完成',
|
||||
progress: 100,
|
||||
priceMudPoints: 1,
|
||||
createdAt: '2026-06-25T00:00:00.000Z',
|
||||
updatedAt: '2026-06-25T00:00:00.000Z',
|
||||
updatedAtMicros: 1_782_348_800_000_000,
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -544,7 +544,10 @@ import type {
|
||||
} from './platformEntryTypes';
|
||||
import { PlatformEntryWorldDetailView } from './PlatformEntryWorldDetailView';
|
||||
import { PlatformErrorDialog } from './PlatformErrorDialog';
|
||||
import { buildExternalGenerationQueueStatus } from './platformExternalGenerationQueueStatusModel';
|
||||
import {
|
||||
buildExternalGenerationQueueStatus,
|
||||
shouldUseFastExternalGenerationTaskPolling,
|
||||
} from './platformExternalGenerationQueueStatusModel';
|
||||
import { resolvePlatformGenerationProgressTickDecision } from './platformGenerationProgressTickModel';
|
||||
import {
|
||||
sendPlatformHostDraftNotificationToHost,
|
||||
@@ -707,6 +710,7 @@ type PuzzleBackgroundCompileTask = {
|
||||
generationState: MiniGameDraftGenerationState;
|
||||
error: string | null;
|
||||
};
|
||||
const EXTERNAL_GENERATION_TASK_FAST_POLL_INTERVAL_MS = 4_000;
|
||||
|
||||
function isUnacknowledgedExternalGenerationTerminalTask(
|
||||
task: ExternalGenerationTaskRecord,
|
||||
@@ -4967,6 +4971,15 @@ export function PlatformEntryFlowShellImpl({
|
||||
woodenFishGenerationState,
|
||||
);
|
||||
const isHostNetworkOnline = useHostNetworkOnline();
|
||||
const shouldFastPollKnownExternalGenerationTask =
|
||||
puzzleOperation?.queueState?.status === 'queued' ||
|
||||
puzzleOperation?.queueState?.status === 'running' ||
|
||||
jumpHopQueueState?.status === 'queued' ||
|
||||
jumpHopQueueState?.status === 'running' ||
|
||||
puzzleClearQueueState?.status === 'queued' ||
|
||||
puzzleClearQueueState?.status === 'running' ||
|
||||
woodenFishQueueState?.status === 'queued' ||
|
||||
woodenFishQueueState?.status === 'running';
|
||||
const shouldPollExternalGenerationTasks =
|
||||
platformBootstrap.canReadProtectedData && Boolean(authUi?.user?.id);
|
||||
const externalGenerationQueueOwnerKey =
|
||||
@@ -4991,6 +5004,21 @@ export function PlatformEntryFlowShellImpl({
|
||||
|
||||
let disposed = false;
|
||||
let controller: AbortController | null = null;
|
||||
let timerId: number | null = null;
|
||||
|
||||
const scheduleNextRefresh = (delayMs: number) => {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
if (timerId != null) {
|
||||
window.clearTimeout(timerId);
|
||||
timerId = null;
|
||||
}
|
||||
if (delayMs <= 0) {
|
||||
return;
|
||||
}
|
||||
timerId = window.setTimeout(refreshTaskList, delayMs);
|
||||
};
|
||||
|
||||
const refreshTaskList = () => {
|
||||
controller?.abort();
|
||||
@@ -5010,6 +5038,18 @@ export function PlatformEntryFlowShellImpl({
|
||||
),
|
||||
);
|
||||
setExternalGenerationTaskOverview(response.overview);
|
||||
scheduleNextRefresh(
|
||||
shouldFastPollKnownExternalGenerationTask ||
|
||||
shouldUseFastExternalGenerationTaskPolling(
|
||||
buildExternalGenerationQueueStatus(
|
||||
response.overview,
|
||||
null,
|
||||
response.tasks,
|
||||
),
|
||||
)
|
||||
? EXTERNAL_GENERATION_TASK_FAST_POLL_INTERVAL_MS
|
||||
: 0,
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -5021,16 +5061,28 @@ export function PlatformEntryFlowShellImpl({
|
||||
};
|
||||
|
||||
refreshTaskList();
|
||||
const intervalId = window.setInterval(refreshTaskList, 4000);
|
||||
const handleResume = () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
return;
|
||||
}
|
||||
refreshTaskList();
|
||||
};
|
||||
window.addEventListener('focus', handleResume);
|
||||
document.addEventListener('visibilitychange', handleResume);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
controller?.abort();
|
||||
window.clearInterval(intervalId);
|
||||
if (timerId != null) {
|
||||
window.clearTimeout(timerId);
|
||||
}
|
||||
window.removeEventListener('focus', handleResume);
|
||||
document.removeEventListener('visibilitychange', handleResume);
|
||||
};
|
||||
}, [
|
||||
externalGenerationQueueOwnerKey,
|
||||
isHostNetworkOnline,
|
||||
shouldFastPollKnownExternalGenerationTask,
|
||||
shouldPollExternalGenerationTasks,
|
||||
]);
|
||||
const activeExternalGenerationJobState = useMemo(() => {
|
||||
|
||||
@@ -55,6 +55,48 @@ export function buildExternalGenerationQueueStatus(
|
||||
};
|
||||
}
|
||||
|
||||
function isActiveExternalGenerationStatus(
|
||||
status: ExternalGenerationQueueStatus['currentStatus'],
|
||||
) {
|
||||
return status === 'queued' || status === 'running';
|
||||
}
|
||||
|
||||
function isUnacknowledgedExternalGenerationTerminalTask(
|
||||
task: ExternalGenerationTaskRecord,
|
||||
) {
|
||||
return (
|
||||
(task.status === 'completed' || task.status === 'failed') &&
|
||||
!task.notificationAcknowledgedAt
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldUseFastExternalGenerationTaskPolling(
|
||||
status: ExternalGenerationQueueStatus | null | undefined,
|
||||
) {
|
||||
const pendingCount = normalizeExternalGenerationQueueCount(
|
||||
status?.pendingCount,
|
||||
);
|
||||
const runningCount = normalizeExternalGenerationQueueCount(
|
||||
status?.runningCount,
|
||||
);
|
||||
const unacknowledgedTerminalCount = normalizeExternalGenerationQueueCount(
|
||||
status?.unacknowledgedTerminalCount,
|
||||
);
|
||||
|
||||
return Boolean(
|
||||
isActiveExternalGenerationStatus(status?.currentStatus ?? null) ||
|
||||
pendingCount > 0 ||
|
||||
runningCount > 0 ||
|
||||
unacknowledgedTerminalCount > 0 ||
|
||||
(status?.tasks ?? []).some(
|
||||
(task) =>
|
||||
task.status === 'queued' ||
|
||||
task.status === 'running' ||
|
||||
isUnacknowledgedExternalGenerationTerminalTask(task),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveExternalGenerationQueueStatusLabel(
|
||||
status: ExternalGenerationQueueStatus['currentStatus'],
|
||||
) {
|
||||
|
||||
@@ -496,15 +496,44 @@ describe('assetReadUrlService', () => {
|
||||
expect(window.dispatchEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('readAssetBytes reads generated resources through same-origin bytes endpoint', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(new Uint8Array([104, 101, 108, 108, 111]), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'image/png',
|
||||
},
|
||||
}),
|
||||
);
|
||||
test('readAssetBytes reads generated resources through signed OSS url first', async () => {
|
||||
vi.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
data: {
|
||||
read: {
|
||||
objectKey:
|
||||
'generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png',
|
||||
signedUrl: 'https://signed.example.com/match3d-item.png',
|
||||
expiresAt: '2099-01-01T00:10:00Z',
|
||||
},
|
||||
},
|
||||
error: null,
|
||||
meta: {
|
||||
apiVersion: '2026-06-16',
|
||||
routeVersion: '2026-06-16',
|
||||
latencyMs: 1,
|
||||
timestamp: '2099-01-01T00:00:00Z',
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(new Uint8Array([104, 101, 108, 108, 111]), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'image/png',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await readAssetBytes(
|
||||
'/generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png',
|
||||
@@ -515,22 +544,53 @@ describe('assetReadUrlService', () => {
|
||||
expect(Array.from(bytes)).toEqual([104, 101, 108, 108, 111]);
|
||||
expect(response.headers.get('content-type')).toBe('image/png');
|
||||
expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).toContain(
|
||||
'/api/assets/read-bytes?',
|
||||
'/api/assets/read-url?',
|
||||
);
|
||||
expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).toContain(
|
||||
'legacyPublicPath=%2Fgenerated-match3d-assets%2Fsession%2Fprofile%2Fitems%2Fmatch3d-item-1-item%2Fimage.png',
|
||||
);
|
||||
expect(String(vi.mocked(globalThis.fetch).mock.calls[1]?.[0])).toBe(
|
||||
'https://signed.example.com/match3d-item.png',
|
||||
);
|
||||
});
|
||||
|
||||
test('readAssetBytes reads object-key resources through same-origin bytes endpoint', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(new Uint8Array([1, 2, 3]), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'image/png',
|
||||
},
|
||||
}),
|
||||
);
|
||||
test('readAssetBytes reads object-key resources through signed OSS url first', async () => {
|
||||
vi.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
data: {
|
||||
read: {
|
||||
objectKey: 'generated-editor-images/project/image.png',
|
||||
signedUrl: 'https://signed.example.com/editor-image.png',
|
||||
expiresAt: '2099-01-01T00:10:00Z',
|
||||
},
|
||||
},
|
||||
error: null,
|
||||
meta: {
|
||||
apiVersion: '2026-06-16',
|
||||
routeVersion: '2026-06-16',
|
||||
latencyMs: 1,
|
||||
timestamp: '2099-01-01T00:00:00Z',
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(new Uint8Array([1, 2, 3]), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'image/png',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await readAssetBytes('/generated-editor-images/image.png', {
|
||||
objectKey: 'generated-editor-images/project/image.png',
|
||||
@@ -540,7 +600,7 @@ describe('assetReadUrlService', () => {
|
||||
|
||||
expect(Array.from(bytes)).toEqual([1, 2, 3]);
|
||||
expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).toContain(
|
||||
'/api/assets/read-bytes?',
|
||||
'/api/assets/read-url?',
|
||||
);
|
||||
expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).toContain(
|
||||
'objectKey=generated-editor-images%2Fproject%2Fimage.png',
|
||||
@@ -548,17 +608,49 @@ describe('assetReadUrlService', () => {
|
||||
expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).not.toContain(
|
||||
'legacyPublicPath=',
|
||||
);
|
||||
expect(String(vi.mocked(globalThis.fetch).mock.calls[1]?.[0])).toBe(
|
||||
'https://signed.example.com/editor-image.png',
|
||||
);
|
||||
});
|
||||
|
||||
test('readAssetBytes normalizes full OSS generated urls through bytes endpoint', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(new Uint8Array([1, 2, 3]), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'image/png',
|
||||
},
|
||||
}),
|
||||
);
|
||||
test('readAssetBytes normalizes full OSS generated urls through signed read url', async () => {
|
||||
vi.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
data: {
|
||||
read: {
|
||||
objectKey:
|
||||
'generated-puzzle-assets/session/profile/covers/main.png',
|
||||
signedUrl: 'https://signed.example.com/main.png',
|
||||
expiresAt: '2099-01-01T00:10:00Z',
|
||||
},
|
||||
},
|
||||
error: null,
|
||||
meta: {
|
||||
apiVersion: '2026-06-16',
|
||||
routeVersion: '2026-06-16',
|
||||
latencyMs: 1,
|
||||
timestamp: '2099-01-01T00:00:00Z',
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(new Uint8Array([1, 2, 3]), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'image/png',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await readAssetBytes(
|
||||
'https://genarrative.oss-cn-shanghai.aliyuncs.com/generated-puzzle-assets/session/profile/covers/main.png?x-oss-signature=abc',
|
||||
@@ -567,10 +659,70 @@ describe('assetReadUrlService', () => {
|
||||
|
||||
expect(response.headers.get('content-type')).toBe('image/png');
|
||||
expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).toContain(
|
||||
'/api/assets/read-bytes?',
|
||||
'/api/assets/read-url?',
|
||||
);
|
||||
expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).toContain(
|
||||
'legacyPublicPath=%2Fgenerated-puzzle-assets%2Fsession%2Fprofile%2Fcovers%2Fmain.png',
|
||||
);
|
||||
expect(String(vi.mocked(globalThis.fetch).mock.calls[1]?.[0])).toBe(
|
||||
'https://signed.example.com/main.png',
|
||||
);
|
||||
});
|
||||
|
||||
test('readAssetBytes falls back to same-origin bytes endpoint when OSS read fails', async () => {
|
||||
vi.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
data: {
|
||||
read: {
|
||||
objectKey: 'generated-editor-images/project/image.png',
|
||||
signedUrl: 'https://signed.example.com/editor-image.png',
|
||||
expiresAt: '2099-01-01T00:10:00Z',
|
||||
},
|
||||
},
|
||||
error: null,
|
||||
meta: {
|
||||
apiVersion: '2026-06-16',
|
||||
routeVersion: '2026-06-16',
|
||||
latencyMs: 1,
|
||||
timestamp: '2099-01-01T00:00:00Z',
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
.mockResolvedValueOnce(new Response('', { status: 403 }))
|
||||
.mockResolvedValueOnce(
|
||||
new Response(new Uint8Array([9, 8, 7]), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'image/png',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await readAssetBytes('/generated-editor-images/image.png', {
|
||||
objectKey: 'generated-editor-images/project/image.png',
|
||||
expireSeconds: 300,
|
||||
});
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
|
||||
expect(Array.from(bytes)).toEqual([9, 8, 7]);
|
||||
expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).toContain(
|
||||
'/api/assets/read-url?',
|
||||
);
|
||||
expect(String(vi.mocked(globalThis.fetch).mock.calls[1]?.[0])).toBe(
|
||||
'https://signed.example.com/editor-image.png',
|
||||
);
|
||||
expect(String(vi.mocked(globalThis.fetch).mock.calls[2]?.[0])).toContain(
|
||||
'/api/assets/read-bytes?',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -548,17 +548,41 @@ export async function readAssetBytes(
|
||||
return response;
|
||||
}
|
||||
|
||||
// 中文注释:这里要拿图片字节转 Data URL,不能直接 fetch OSS 签名 URL,否则浏览器会受 bucket CORS 限制。
|
||||
const searchParams = buildAssetReadSearchParams({
|
||||
const readRequest = {
|
||||
objectKey,
|
||||
legacyPublicPath: objectKey ? undefined : legacyPath,
|
||||
expireSeconds: options.expireSeconds,
|
||||
};
|
||||
try {
|
||||
const signedUrl = await getSignedAssetReadUrl(readRequest, options.signal);
|
||||
const response = await fetch(signedUrl, { signal: options.signal });
|
||||
if (response.ok) {
|
||||
return response;
|
||||
}
|
||||
} catch {
|
||||
if (options.signal?.aborted) {
|
||||
throw createSignedReadUrlAbortError();
|
||||
}
|
||||
// 中文注释:浏览器直读 OSS 失败时再走同源字节代理兜底。
|
||||
}
|
||||
|
||||
return readAssetBytesViaFallbackApi(readRequest, options.signal);
|
||||
}
|
||||
|
||||
async function readAssetBytesViaFallbackApi(
|
||||
request: AssetReadUrlRequest,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const searchParams = buildAssetReadSearchParams({
|
||||
objectKey: request.objectKey,
|
||||
legacyPublicPath: request.legacyPublicPath,
|
||||
expireSeconds: request.expireSeconds,
|
||||
});
|
||||
const response = await fetchWithApiAuth(
|
||||
`${ASSET_READ_BYTES_API_PATH}?${searchParams.toString()}`,
|
||||
{
|
||||
method: 'GET',
|
||||
signal: options.signal,
|
||||
signal,
|
||||
},
|
||||
{
|
||||
...ASSET_READ_URL_BACKGROUND_OPTIONS,
|
||||
|
||||
@@ -27,14 +27,29 @@ describe('match3dGeneratedModelCache', () => {
|
||||
|
||||
test('预加载生成模型字节并复用本地缓存', async () => {
|
||||
setStoredAccessToken('test-access-token', { emit: false });
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(new Uint8Array([103, 108, 84, 70]), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'model/gltf-binary',
|
||||
},
|
||||
}),
|
||||
);
|
||||
vi.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
read: {
|
||||
signedUrl: 'https://oss.example.com/model.glb',
|
||||
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
},
|
||||
),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(new Uint8Array([103, 108, 84, 70]), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'model/gltf-binary',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await preloadMatch3DGeneratedModelAssets(
|
||||
[
|
||||
@@ -61,7 +76,13 @@ describe('match3dGeneratedModelCache', () => {
|
||||
);
|
||||
|
||||
expect(Array.from(new Uint8Array(bytes))).toEqual([103, 108, 84, 70]);
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(2);
|
||||
expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).toContain(
|
||||
'/api/assets/read-url',
|
||||
);
|
||||
expect(String(vi.mocked(globalThis.fetch).mock.calls[1]?.[0])).toBe(
|
||||
'https://oss.example.com/model.glb',
|
||||
);
|
||||
});
|
||||
|
||||
test('模型源列表会去重并兼容 modelObjectKey', () => {
|
||||
|
||||
Reference in New Issue
Block a user