From a68a9621a74b56a9ed13c633f59b948aea67aa03 Mon Sep 17 00:00:00 2001 From: Linghong Date: Thu, 17 Sep 2026 11:56:17 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=89=8D=E7=AB=AF=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E7=8A=B6=E6=80=81=E6=B3=84=E6=BC=8F=E4=B8=8E=E5=BC=82?= =?UTF-8?q?=E6=AD=A5=E6=96=AD=E8=A8=80=E7=AB=9E=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 隔离创作主页换签网络请求并使用独立响应 恢复快捷键选择状态、持久化路由和UI编辑页宿主mock 修正虚拟树尺寸与选择时序并纳入换签Hook测试 记录全量与随机顺序测试验证方式 --- .../tests/uiEditorPage.test.ts | 52 ++++++++++++++----- .../shared-memory/development-workflow.md | 4 ++ .../CreationLandingView.test.tsx | 43 +++++++++++---- .../useImageCanvasKeyboardShortcuts.test.tsx | 6 ++- .../useImageCanvasProjectPersistence.test.tsx | 1 + src/hooks/useResolvedAssetReadUrl.test.tsx | 34 ++++++++---- vitest.config.ts | 1 + 7 files changed, 106 insertions(+), 35 deletions(-) diff --git a/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts b/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts index f9615a790..d631c5051 100644 --- a/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts +++ b/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts @@ -2,6 +2,7 @@ import { act, + cleanup, fireEvent, render, renderHook, @@ -9,7 +10,7 @@ import { waitFor, } from '@testing-library/react'; import { createElement, type ReactNode } from 'react'; -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn() })); @@ -35,13 +36,21 @@ import UiEditorPage from '../src/view/ui-editor'; import { useUiEditorSession } from '../src/view/ui-editor/useUiEditorPage'; class TestResizeObserver { - constructor(_callback: ResizeObserverCallback) {} - observe() {} + constructor(private callback: ResizeObserverCallback) {} + observe(target: Element) { + this.callback( + [ + { + target, + contentRect: { width: 800, height: 600 }, + } as ResizeObserverEntry, + ], + this as unknown as ResizeObserver, + ); + } disconnect() {} } -vi.stubGlobal('ResizeObserver', TestResizeObserver); - const EMPTY_SNAPSHOT: UiDesignStateSnapshot = { revision: 0, state: { @@ -147,6 +156,16 @@ async function renderLoadedSession(state: State) { } describe('UiEditorPage', () => { + beforeEach(() => { + vi.stubGlobal('ResizeObserver', TestResizeObserver); + vi.mocked(invoke).mockReset().mockResolvedValue(undefined); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + }); + it('keeps the wallet entry in the resource editor header', async () => { const stateStore: IUiDesignStateStore = { load: vi.fn().mockResolvedValue(structuredClone(EMPTY_SNAPSHOT)), @@ -185,10 +204,7 @@ describe('UiEditorPage', () => { } return undefined; }); - Object.defineProperty(window, '__TAURI__', { - configurable: true, - value: { core: { invoke } }, - }); + vi.stubGlobal('__TAURI__', { core: { invoke } }); const stateStore: IUiDesignStateStore = { load: vi.fn().mockResolvedValue({ revision: 0, @@ -509,8 +525,13 @@ describe('UiEditorPage', () => { }), ); - const child = await screen.findByText('page-child'); - fireEvent.click(child); + await waitFor(() => { + expect( + screen.getByRole('button', { name: '保存' }).hasAttribute('disabled'), + ).toBe(false); + }); + fireEvent.click(screen.getByText('page-child')); + await screen.findByDisplayValue('page-child'); fireEvent.keyDown(window, { key: 'Delete' }); await waitFor(() => expect(screen.queryByText('page-child')).toBeNull()); @@ -533,8 +554,13 @@ describe('UiEditorPage', () => { }), ); - const child = await screen.findByText('page-child'); - fireEvent.click(child); + await waitFor(() => { + expect( + screen.getByRole('button', { name: '保存' }).hasAttribute('disabled'), + ).toBe(false); + }); + fireEvent.click(screen.getByText('page-child')); + await screen.findByDisplayValue('page-child'); const dialog = document.createElement('div'); dialog.setAttribute('role', 'dialog'); document.body.appendChild(dialog); diff --git a/docs/project-memory/shared-memory/development-workflow.md b/docs/project-memory/shared-memory/development-workflow.md index 3dd59f677..36fbbe575 100644 --- a/docs/project-memory/shared-memory/development-workflow.md +++ b/docs/project-memory/shared-memory/development-workflow.md @@ -4,6 +4,10 @@ ## 标准流程 +前端测试稳定性验证使用根目录 `npm test`(与 Frontend tests job 相同),保留 Vitest 的 8 worker 上限。涉及异步资源展示时,组件测试必须 mock 所有会触发的网络请求,每次调用创建独立 `Response`,并等待最终 DOM 状态而非仅等待 fetch 被调用。换签 Hook 的测试通过 `vitest.config.ts` 的 include 纳入全量运行;新增测试文件后需确认实际执行名单,命令参数指定文件不会绕过 include 白名单。排查顺序依赖可使用 `npm test -- --sequence.shuffle --sequence.seed=9467`,但不能以重试成功替代失败原因分析。 + +用例隔离必须包括浏览器状态与 mock 实现:修改 `window.history` 后恢复基线路由;`spyOn(window, 'getSelection')` 等 spy 在用例结束后 restore;`clearAllMocks` 仅清调用记录,不能恢复被上一个用例替换的返回值。顺序打乱暴露的失败应修复泄漏来源,保留原有业务断言。 + ```text 确认工作树与目标分支 → 读取入口和当前专题 → 查代码真相 → 小步修改 → 定向验证 → 更新当前文档/记忆 → 检查提交边界 ``` diff --git a/src/components/creation-home/CreationLandingView.test.tsx b/src/components/creation-home/CreationLandingView.test.tsx index 656ef8f46..d0c59ef83 100644 --- a/src/components/creation-home/CreationLandingView.test.tsx +++ b/src/components/creation-home/CreationLandingView.test.tsx @@ -155,6 +155,26 @@ function SuspendAfterLandingRender({ active }: { active: boolean }) { describe('CreationLandingView', () => { beforeEach(() => { clearSignedAssetReadUrlCache(); + // 每次换签返回独立响应,筛选等用例也不能触发真实网络请求。 + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL) => { + const url = new URL(String(input), 'http://localhost'); + if (url.pathname !== '/api/assets/read-url') { + throw new Error(`Unexpected test request: ${url.pathname}`); + } + return new Response( + JSON.stringify({ + read: { + objectKey: url.searchParams.get('objectKey') ?? '', + signedUrl: 'data:image/png;base64,dGVzdA==', + expiresAt: '2099-01-01T00:00:00Z', + }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ); + }), + ); listPublicEditorProjectResourcesMock.mockResolvedValue([]); }); @@ -1304,17 +1324,18 @@ describe('CreationLandingView', () => { const campaignObjectKey = 'generated-character-drafts/editor/showcase-campaign/current/campaign.png'; const signedCampaignUrl = 'data:image/png;base64,Y2FtcGFpZ24='; - const fetchMock = vi.fn().mockResolvedValue( - new Response( - JSON.stringify({ - read: { - objectKey: campaignObjectKey, - signedUrl: signedCampaignUrl, - expiresAt: '2099-01-01T00:00:00Z', - }, - }), - { status: 200, headers: { 'content-type': 'application/json' } }, - ), + const fetchMock = vi.fn().mockImplementation( + async () => + new Response( + JSON.stringify({ + read: { + objectKey: campaignObjectKey, + signedUrl: signedCampaignUrl, + expiresAt: '2099-01-01T00:00:00Z', + }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), ); vi.stubGlobal('fetch', fetchMock); listEditorProjectsMock.mockResolvedValue(projectItems); diff --git a/src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx b/src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx index 6018727b4..e29b81828 100644 --- a/src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx +++ b/src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx @@ -2,7 +2,7 @@ import { act, fireEvent, render, screen } from '@testing-library/react'; import { type Dispatch, type SetStateAction, useRef, useState } from 'react'; -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import type { CanvasGenerationDialogState, @@ -258,6 +258,10 @@ function KeyboardShortcutsHarness({ } describe('useImageCanvasKeyboardShortcuts', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + it('routes undo and redo while ignoring editable inputs', () => { const undoCanvasChange = vi.fn(); const redoCanvasChange = vi.fn(); diff --git a/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx b/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx index e33258343..ddcd5eaa6 100644 --- a/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx +++ b/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx @@ -954,6 +954,7 @@ describe('useImageCanvasProjectPersistence', () => { beforeEach(() => { vi.resetAllMocks(); + window.history.replaceState(null, '', '/editor/canvas'); try { globalThis.sessionStorage?.clear(); } catch { diff --git a/src/hooks/useResolvedAssetReadUrl.test.tsx b/src/hooks/useResolvedAssetReadUrl.test.tsx index a5094abb1..0ffd68ffd 100644 --- a/src/hooks/useResolvedAssetReadUrl.test.tsx +++ b/src/hooks/useResolvedAssetReadUrl.test.tsx @@ -3,6 +3,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { RUNTIME_RESOURCE_PENDING_SELECTOR } from '../components/common/RuntimeResourcePendingMarker'; import { ResolvedAssetImage } from '../components/ResolvedAssetImage'; import { clearStoredAccessToken, @@ -215,6 +216,7 @@ describe('useResolvedAssetReadUrl', () => { }); test('refreshKey changes force a fresh signed url request without mutating OSS signature query', async () => { + let requestCount = 0; vi.spyOn(globalThis, 'fetch').mockImplementation( async () => new Response( @@ -224,7 +226,7 @@ describe('useResolvedAssetReadUrl', () => { read: { objectKey: 'generated-puzzle-assets/puzzle-session-1/candidate-1/asset-1/image.png', - signedUrl: 'https://signed.example.com/puzzle.png', + signedUrl: `https://signed.example.com/puzzle.png?x-oss-signature=version-${++requestCount}`, expiresAt: '2099-01-01T00:10:00Z', }, }, @@ -255,7 +257,7 @@ describe('useResolvedAssetReadUrl', () => { const firstImage = await screen.findByRole('img', { name: '候选图' }); expect(firstImage.getAttribute('src')).toBe( - 'https://signed.example.com/puzzle.png', + 'https://signed.example.com/puzzle.png?x-oss-signature=version-1', ); rerender( @@ -267,11 +269,11 @@ describe('useResolvedAssetReadUrl', () => { ); await waitFor(() => { - expect(globalThis.fetch).toHaveBeenCalledTimes(2); + expect( + screen.getByRole('img', { name: '候选图' }).getAttribute('src'), + ).toBe('https://signed.example.com/puzzle.png?x-oss-signature=version-2'); }); - expect( - screen.getByRole('img', { name: '候选图' }).getAttribute('src'), - ).toBe('https://signed.example.com/puzzle.png'); + expect(globalThis.fetch).toHaveBeenCalledTimes(2); }); test('generated 私有资源签名失败时保持空图像而不是回退裸路径', async () => { @@ -300,16 +302,22 @@ describe('useResolvedAssetReadUrl', () => { ), ); - render( + const { container } = render( , ); + expect( + container.querySelector(RUNTIME_RESOURCE_PENDING_SELECTOR), + ).not.toBeNull(); await waitFor(() => { - expect(globalThis.fetch).toHaveBeenCalledTimes(1); + expect( + container.querySelector(RUNTIME_RESOURCE_PENDING_SELECTOR), + ).toBeNull(); }); + expect(globalThis.fetch).toHaveBeenCalledTimes(1); expect(screen.queryByRole('img', { name: '候选图' })).toBeNull(); }); @@ -339,7 +347,7 @@ describe('useResolvedAssetReadUrl', () => { ), ); - render( + const { container } = render( { expect( screen.getByRole('img', { name: '候选图' }).getAttribute('src'), ).toBe('/creation-type-references/puzzle.webp'); + expect( + container.querySelector(RUNTIME_RESOURCE_PENDING_SELECTOR), + ).not.toBeNull(); await waitFor(() => { - expect(globalThis.fetch).toHaveBeenCalledTimes(1); + expect( + container.querySelector(RUNTIME_RESOURCE_PENDING_SELECTOR), + ).toBeNull(); }); + expect(globalThis.fetch).toHaveBeenCalledTimes(1); expect( screen.getByRole('img', { name: '候选图' }).getAttribute('src'), ).toBe('/creation-type-references/puzzle.webp'); diff --git a/vitest.config.ts b/vitest.config.ts index 0af75894a..4d6d56888 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -119,6 +119,7 @@ export default defineConfig({ 'src/components/platform-entry/platformProfile*.test.ts', 'src/components/platform-entry/usePlatformProfileCenterController*.test.tsx', 'src/hooks/useHostNavigationCanGoBack.test.tsx', + 'src/hooks/useResolvedAssetReadUrl.test.tsx', 'apps/admin-web/src/**/*.test.ts', 'apps/admin-web/src/**/*.test.tsx', 'apps/ai-game-creator-shell/tests/**/*.test.ts',