206 lines
9.8 KiB
TypeScript
206 lines
9.8 KiB
TypeScript
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', 'assets', '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(9)
|
|
expect(mounted).toHaveBeenCalledExactlyOnceWith('production')
|
|
})
|
|
|
|
it('切换项目与首次读取期间不能沿用上一个已完成项目的权限', async () => {
|
|
let resolveSecond!: (value: ProjectDetail) => void
|
|
const pending = new Promise<ProjectDetail>(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(9)
|
|
await router.push('/projects/second/production')
|
|
await flushPromises()
|
|
expect(wrapper!.findAll('.n-menu a')).toHaveLength(3)
|
|
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(2)
|
|
})
|
|
|
|
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(3)
|
|
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(/<NCheckbox\b[^>]*class="[^"]*pb-[23]/)
|
|
}
|
|
})
|
|
})
|