import { h } from 'vue' import { createMemoryHistory, createRouter } from 'vue-router' import { flushPromises, mount, type VueWrapper } from '@vue/test-utils' import { afterEach, describe, expect, it, vi } from 'vitest' import { readFileSync } from 'node:fs' import App from '@/App.vue' import ProjectLayout from '@/features/projects/ProjectLayout.vue' import { isProjectComplete } from '@/features/projects/access' import { projectsApi } from '@/features/projects/api' import type { ProjectDetail, ProjectStatus } from '@/features/projects/types' import { readAllStyles } from '@/testing/styles' const downstream = ['breakdown', 'visual-style', 'subject-identity', 'subject-images', 'storyboard', 'production'] let wrapper: VueWrapper | undefined afterEach(() => { wrapper?.unmount() wrapper = undefined document.body.innerHTML = '' localStorage.clear() vi.useRealTimers() vi.restoreAllMocks() }) /** 用已有剧集模拟中途生成的项目,完成与否必须取 status 而非数组长度。 */ function project(id: string, status: ProjectStatus): ProjectDetail { return { id, status, title: '访问限制测试', topic: '', style: null, createdAt: '', updatedAt: '', episodes: [{ episode: 1, title: '部分剧集', content: '已经写入的内容' }], characters: [], world: null } } /** 真实项目布局与侧栏,子工作区以挂载探针代替,防止测试发起实际生成请求。 */ async function openProject(initialPath: string) { const mounted = vi.fn<(path: string) => void>() const router = createRouter({ history: createMemoryHistory(), routes: [ { path: '/projects', component: { render: () => h('div', '项目列表') } }, { path: '/projects/:projectId', component: ProjectLayout, children: ['create-drama', ...downstream].map(path => ({ path, component: { setup() { mounted(path) return () => h('div', { class: 'workspace-probe' }, path) } } })) } ] }) await router.push(initialPath) wrapper = mount(App, { attachTo: document.body, global: { plugins: [router] } }) await flushPromises() return { router, mounted } } describe('剧本完成前的下游访问限制', () => { it('项目工作区停留期间不重复读取项目详情和工作流记录', async () => { vi.useFakeTimers() const detail = vi.spyOn(projectsApi, 'detail').mockResolvedValue(project('manual-project', 'completed')) const checkpoints = vi.spyOn(projectsApi, 'checkpoints').mockResolvedValue([]) await openProject('/projects/manual-project/production') await vi.advanceTimersByTimeAsync(30_000) expect(detail).toHaveBeenCalledTimes(1) expect(checkpoints).toHaveBeenCalledTimes(1) }) it('图库直接链接只读取一次项目,未完成时可手动刷新解锁', async () => { vi.useFakeTimers() let status: ProjectStatus = 'generating' const detail = vi.spyOn(projectsApi, 'detail').mockImplementation(async id => project(id, status)) vi.spyOn(projectsApi, 'checkpoints').mockResolvedValue([]) await openProject('/projects/manual-gate/subject-images') await vi.advanceTimersByTimeAsync(30_000) expect(detail).toHaveBeenCalledTimes(1) expect(wrapper!.get('.project-access-gate').text()).toContain('请手动刷新项目状态') status = 'completed' const refresh = wrapper!.findAll('button').find(button => button.text() === '刷新项目状态')! await refresh.trigger('click') await flushPromises() expect(detail).toHaveBeenCalledTimes(2) expect(wrapper!.get('.workspace-probe').text()).toBe('subject-images') }) it.each(['draft', 'generating', 'need_review', 'failed'] as const)( '%s 不解锁导航,也不挂载直接链接对应的工作区', async status => { vi.spyOn(projectsApi, 'detail').mockResolvedValue(project('unfinished', status)) vi.spyOn(projectsApi, 'checkpoints').mockResolvedValue([]) const { router, mounted } = await openProject('/projects/unfinished/production') for (const path of downstream) { await router.push(`/projects/unfinished/${path}`) await flushPromises() expect(wrapper!.get('.project-access-gate').text()).toContain('请先完成剧本创作') expect(wrapper!.find('.workspace-probe').exists()).toBe(false) expect(wrapper!.find(`.n-menu a[href="/projects/unfinished/${path}"]`).exists()).toBe(false) } expect(mounted).not.toHaveBeenCalled() expect(wrapper!.findAll('.n-menu [aria-disabled="true"]')).toHaveLength(6) await wrapper!.get('.project-access-gate button').trigger('click') await flushPromises() expect(router.currentRoute.value.path).toBe('/projects/unfinished/create-drama') expect(wrapper!.get('.workspace-probe').text()).toBe('create-drama') expect(mounted).toHaveBeenCalledExactlyOnceWith('create-drama') } ) it('未完成项目停留期间不自动解锁,仅在手动刷新后进入下游页面', async () => { vi.useFakeTimers() let status: ProjectStatus = 'generating' vi.spyOn(projectsApi, 'detail').mockImplementation(async id => project(id, status)) vi.spyOn(projectsApi, 'checkpoints').mockResolvedValue([]) const { mounted } = await openProject('/projects/manual-refresh/production') expect(mounted).not.toHaveBeenCalled() status = 'completed' await vi.advanceTimersByTimeAsync(6000) await flushPromises() expect(wrapper!.find('.workspace-probe').exists()).toBe(false) await wrapper!.get('.project-access-gate button:nth-of-type(2)').trigger('click') await flushPromises() expect(wrapper!.get('.workspace-probe').text()).toBe('production') expect(wrapper!.findAll('.n-menu a')).toHaveLength(8) expect(mounted).toHaveBeenCalledExactlyOnceWith('production') }) it('切换项目与首次读取期间不能沿用上一个已完成项目的权限', async () => { let resolveSecond!: (value: ProjectDetail) => void const pending = new Promise(resolve => { resolveSecond = resolve }) vi.spyOn(projectsApi, 'detail').mockImplementation(async id => id === 'first' ? project(id, 'completed') : pending ) vi.spyOn(projectsApi, 'checkpoints').mockResolvedValue([]) const { router, mounted } = await openProject('/projects/first/production') expect(wrapper!.findAll('.n-menu a')).toHaveLength(8) await router.push('/projects/second/production') await flushPromises() expect(wrapper!.findAll('.n-menu a')).toHaveLength(2) expect(wrapper!.find('.workspace-probe').exists()).toBe(false) resolveSecond(project('second', 'draft')) await flushPromises() expect(wrapper!.find('.workspace-probe').exists()).toBe(false) expect(mounted).toHaveBeenCalledExactlyOnceWith('production') await router.push('/projects') await flushPromises() expect(wrapper!.findAll('.n-menu a')).toHaveLength(1) }) it('读取失败保持锁定;错误区重试成功后解锁且不恢复项目标题栏', async () => { const detail = vi .spyOn(projectsApi, 'detail') .mockRejectedValueOnce(new Error('项目读取失败')) .mockResolvedValue(project('retry', 'completed')) vi.spyOn(projectsApi, 'checkpoints').mockResolvedValue([]) const { mounted } = await openProject('/projects/retry/storyboard') expect(mounted).not.toHaveBeenCalled() expect(wrapper!.findAll('.n-menu a')).toHaveLength(2) expect(wrapper!.find('.project-header').exists()).toBe(false) await wrapper!.get('.project-notices button').trigger('click') await flushPromises() expect(detail).toHaveBeenCalledTimes(2) expect(mounted).toHaveBeenCalledExactlyOnceWith('storyboard') }) it('不存在、未知状态或项目 ID 不匹配时默认锁定', () => { expect(isProjectComplete(null, 'p')).toBe(false) expect(isProjectComplete({ id: 'other', status: 'completed' }, 'p')).toBe(false) expect(isProjectComplete({ id: 'p', status: 'unknown' as ProjectStatus }, 'p')).toBe(false) expect(isProjectComplete({ id: 'p', status: 'completed' }, 'p')).toBe(true) }) it('配置复选框居中对齐,输入表面随面板背景分层而非增加边框', () => { // 保护布局契约;实际像素对齐仍需浏览器视觉验收。 const css = readAllStyles() expect(css).toMatch( /\.n-checkbox\.control-row-checkbox\s*\{[^}]*align-items:\s*center;[^}]*min-height:\s*34px;[^}]*padding-block:\s*0/ ) expect(css).toMatch(/\.app-dialog\s*\{[^}]*--app-field:\s*var\(--app-control\)/) expect(readFileSync('src/styles/styles.css', 'utf8')).toMatch( /\.panel\s*\{[^}]*--app-field:\s*var\(--app-control\)/ ) for (const path of [ 'production/ProductionPage.vue', 'storyboard/StoryboardPage.vue', 'subject-identity/SubjectIdentityPage.vue', 'subject-images/SubjectImagesPage.vue' ]) { const source = readFileSync(`src/features/${path}`, 'utf8') expect(source).toContain('control-row-checkbox') expect(source).not.toMatch(/]*class="[^"]*pb-[23]/) } }) })