diff --git a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts index 4dc751f6f..fc9384003 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts @@ -338,6 +338,21 @@ export function useProjectResourceCardPreviews(input: { const observedCardsRef = useRef(new Map()); const queueRef = useRef([]); const queuedIdentitiesRef = useRef(new Map()); + /** + * 被队列上限吞掉的**按需**请求(`detail` / `play`)。 + * + * 这类丢弃此前完全不可见:用户在可见卡上点播放,没有请求、没有报错、没有 loading。 + * 记为单调计数 + 最后一条明细,供排障与用例观测;`visible` 的背压不计入(那是设计内的)。 + */ + const droppedRequestsRef = useRef<{ + count: number; + last: { + identity: string; + reason: PreviewRequestReason; + queueLength: number; + at: number; + } | null; + }>({ count: 0, last: null }); const pendingIdentitiesRef = useRef(new Map()); const cacheOrderRef = useRef(new Map()); const cachedPreviewsRef = useRef(new Map()); @@ -664,6 +679,28 @@ export function useProjectResourceCardPreviews(input: { } } if (reason === 'visible' || replaceIndex < 0) { + /* + * 队列被占满、又找不到可以顶掉的可见性预取时,这次请求只能丢掉。 + * + * `visible` 撞上限是设计内的背压(下一轮兜底扫描会把仍可见的卡补回来), + * 但 **`detail` / `play` 是按需请求**:用户在可见卡上点了播放却什么都不发生, + * 此前连一条痕迹都没有 —— 这里必须留下可观测标记(快照 + 日志)。 + * 队列上限、3 个并发槽与优先级顺序都不放宽,只是把"被吞掉的那次"记下来。 + */ + if (reason !== 'visible') { + droppedRequestsRef.current = { + count: droppedRequestsRef.current.count + 1, + last: { + identity, + reason, + queueLength: queueRef.current.length, + at: Date.now(), + }, + }; + console.warn( + `[preview-queue] 按需请求被队列上限丢弃 reason=${reason} queueLength=${queueRef.current.length}`, + ); + } return; } const [replacedJob] = queueRef.current.splice(replaceIndex, 1); @@ -1114,6 +1151,10 @@ export function useProjectResourceCardPreviews(input: { })), activeReadCount: activeReadsRef.current.count, prefetchScopeKey: prefetchScopeKeyRef.current, + /** 被队列上限吞掉的按需请求条数;`visible` 的背压不计入。 */ + droppedRequestCount: droppedRequestsRef.current.count, + /** 最近一次被吞掉的按需请求明细,从未发生时为 `null`。 */ + lastDroppedRequest: droppedRequestsRef.current.last, }), }; } diff --git a/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts b/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts index d5d1ad9ab..52f49a3e7 100644 --- a/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts +++ b/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts @@ -7,6 +7,7 @@ import { isProjectResourcePreviewCancellation } from '../src/services/projectRes import { PROJECT_RESOURCE_CARD_PREVIEW_CACHE_BYTE_LIMIT, PROJECT_RESOURCE_CARD_PREVIEW_CACHE_LIMIT, + PROJECT_RESOURCE_CARD_PREVIEW_QUEUE_LIMIT, projectResourceCardPreviewEvictionIdentities, } from '../src/view/project-development/resourceCardPreviewModel'; import type { ProjectResource } from '../src/view/project-development/resourceProjectionModel'; @@ -1613,6 +1614,88 @@ describe('useProjectResourceCardPreviews', () => { }); }); +describe('预览队列上限吞掉按需请求时的可观测性', () => { + it('records an on-demand request that a full queue had to drop', async () => { + // 现场:队列压满 96 条、又找不到可以顶掉的可见性预取时,`detail` / `play` 请求 + // 被直接丢掉 —— 用户在可见卡上点播放,没有请求、没有报错,此前连痕迹都没有。 + const resources = Array.from({ length: 101 }, (_, index) => + resource(`queue-drop-${index.toString().padStart(3, '0')}`), + ); + // 所有读取都挂住:队列只进不出,才能在用例里稳定压满。 + const invoke = vi.fn(() => new Promise(() => undefined)); + window.__TAURI__ = { core: { invoke } }; + const canvasRef = { current: document.createElement('div') }; + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const { result } = renderHook(() => + useProjectResourceCardPreviews({ + projectPath: '/tmp/preview-queue-drop', + projectId: 'preview-queue-drop', + mode: 'dependency', + resources, + canvasRef, + }), + ); + + act(() => { + for (const candidate of resources.slice(0, 99)) { + result.current.requestPreview( + candidate, + result.current.identityByResourceId.get(candidate.id)!, + 'detail', + ); + } + }); + expect(result.current.previewQueueSnapshot().queue).toHaveLength( + PROJECT_RESOURCE_CARD_PREVIEW_QUEUE_LIMIT, + ); + expect(result.current.previewQueueSnapshot().droppedRequestCount).toBe(0); + expect(result.current.previewQueueSnapshot().lastDroppedRequest).toBeNull(); + + const dropped = resources[99]!; + act(() => { + result.current.requestPreview( + dropped, + result.current.identityByResourceId.get(dropped.id)!, + 'play', + ); + }); + + expect(result.current.previewQueueSnapshot().droppedRequestCount).toBe(1); + expect( + result.current.previewQueueSnapshot().lastDroppedRequest, + ).toMatchObject({ + identity: result.current.identityByResourceId.get(dropped.id), + reason: 'play', + queueLength: PROJECT_RESOURCE_CARD_PREVIEW_QUEUE_LIMIT, + }); + expect( + result.current + .previewQueueSnapshot() + .queue.some( + (job) => + job.identity === + result.current.identityByResourceId.get(dropped.id), + ), + ).toBe(false); + expect( + warn.mock.calls.some(([message]) => + String(message).includes('[preview-queue]'), + ), + ).toBe(true); + + // 可见性预取撞上限是设计内的背压,不算"吞掉用户动作",不计数。 + const backpressure = resources[100]!; + act(() => { + result.current.requestPreview( + backpressure, + result.current.identityByResourceId.get(backpressure.id)!, + 'visible', + ); + }); + expect(result.current.previewQueueSnapshot().droppedRequestCount).toBe(1); + }); +}); + describe('预览缓存驱逐后的可见性复核', () => { it('re-requests a card that is still visible right after the LRU evicts it', async () => { // 现场:LRU 只按条数/字节驱逐,不看可见性 —— 当前屏幕上那张卡会被后加载的图挤掉,