Files
short-drama-agent-front/tests/features/create-drama/EpisodeReader.test.ts
T

198 lines
9.8 KiB
TypeScript

import { mount, type VueWrapper } from '@vue/test-utils'
import { nextTick } from 'vue'
import { afterEach, describe, expect, it, vi } from 'vitest'
import EpisodeReader from '@/features/create-drama/EpisodeReader.vue'
import { readAllStyles } from '@/testing/styles'
import type { Episode } from '@/features/projects/types'
const episodes: Episode[] = [
{ episode: 1, title: '来信', summary: '第一集摘要', content: '第一场:旧书店。', conflict: '信件失踪' },
{ episode: 2, title: '雨夜', content: '<script>不要执行模型内容</script>', hook: '门外有人' },
{ episode: 3, title: '重逢', content: '' }
]
let wrapper: VueWrapper | undefined
afterEach(() => {
wrapper?.unmount()
wrapper = undefined
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
/** happy-dom 不计算排版;注入明确的容器几何,但保持真实 NScrollbar 和原生 scroll 事件路径。 */
async function mountReader() {
wrapper = mount(EpisodeReader, { props: { episodes } })
const reader = wrapper.get<HTMLElement>('.reader-scroll .n-scrollbar-container').element
const directory = wrapper.get<HTMLElement>('.reader-directory-scroll .n-scrollbar-container').element
const tops: Record<number, number> = { 1: 32, 2: 700, 3: 1480 }
Object.defineProperty(reader, 'clientHeight', { configurable: true, value: 400 })
Object.defineProperty(directory, 'clientHeight', { configurable: true, value: 164 })
const originalRect = HTMLElement.prototype.getBoundingClientRect
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
if (this === reader) return new DOMRect(0, 100, 600, reader.clientHeight)
if (this === directory) return new DOMRect(0, 50, 200, directory.clientHeight)
if (this.matches('.reader-chapter')) {
return new DOMRect(0, 100 + tops[Number(this.dataset.episode)]! - reader.scrollTop, 600, 600)
}
if (this.matches('.reader-episode-link')) {
return new DOMRect(0, 58 + (Number(this.dataset.episode) - 1) * 72 - directory.scrollTop, 180, 64)
}
return originalRect.call(this)
})
const readerScroll = vi.spyOn(reader, 'scrollTo').mockImplementation((options?: ScrollToOptions | number) => {
if (typeof options !== 'object') return
// 模拟浏览器边界:末集最小高度及上下留白让其标题能抵达阅读线。
reader.scrollTop = Math.max(0, Math.min(options.top ?? 0, Math.max(...Object.values(tops)) - 32))
reader.dispatchEvent(new Event('scroll'))
})
const directoryScroll = vi.spyOn(directory, 'scrollTo').mockImplementation((options?: ScrollToOptions | number) => {
if (typeof options !== 'object') return
directory.scrollTop = Math.max(0, options.top ?? 0)
directory.dispatchEvent(new Event('scroll'))
})
await wrapper.setProps({ episodes: [...episodes] })
return { reader, directory, tops, readerScroll, directoryScroll }
}
/** 获取目录中的唯一当前集,避免只验证按钮类名而漏掉无障碍状态。 */
function currentEpisode() {
const current = wrapper!.findAll('.reader-episode-link[aria-current="location"]')
expect(current).toHaveLength(1)
return Number(current[0]!.attributes('data-episode'))
}
describe('连续剧本阅读器', () => {
it('正文容器靠左并保留目录间距,宽屏不再使用自动外边距居中', () => {
// DOM 测试不计算布局;保护正文定位样式,目录联动仍由后续交互测试验证。
const css = readAllStyles()
const content = css.match(/\.reader-content\s*\{([^}]+)\}/)![1]!
expect(content).toContain('max-width: 900px')
expect(content).toContain('margin-inline: 0')
expect(content).toContain('padding: 32px')
expect(content).toContain('text-align: left')
expect(content).not.toContain('auto')
expect(css).toMatch(/@media \(max-width: 600px\)[\s\S]*?\.reader-content\s*\{\s*padding-inline:\s*18px/)
})
it('按编号连续渲染全部剧集、元数据和空正文,标题不截断,模型内容只按文本显示', () => {
const reversed = episodes.toReversed()
wrapper = mount(EpisodeReader, { props: { episodes: reversed } })
expect(wrapper.findAll('.reader-chapter').map(item => Number(item.attributes('data-episode')))).toEqual([
1, 2, 3
])
expect(reversed[0]?.episode).toBe(3)
expect(wrapper.findAll('.reader-chapter .script-body')).toHaveLength(2)
expect(wrapper.get('.script-summary').text()).toBe('第一集摘要')
expect(wrapper.text()).toContain('信件失踪')
expect(wrapper.text()).toContain('门外有人')
expect(wrapper.text()).toContain('本集正文尚未写入')
expect(wrapper.text()).toContain('<script>不要执行模型内容</script>')
expect(wrapper.find('script').exists()).toBe(false)
expect(wrapper.findAll('.n-scrollbar-container')).toHaveLength(2)
expect(wrapper.get('.reader-scroll [role="region"]').attributes('tabindex')).toBe('0')
expect(wrapper.get('.reader-episode-link').attributes('aria-controls')).toBe(
wrapper.get('.reader-chapter').attributes('id')
)
})
it('点击目录通过 NScrollbar 定位首集、中间集和短末集,不滚动 window', async () => {
const { reader, readerScroll } = await mountReader()
const pageScroll = vi.spyOn(window, 'scrollTo')
for (const [episode, top] of [
[2, 668],
[3, 1448],
[1, 0]
] as const) {
await wrapper!.get(`.reader-episode-link[data-episode="${episode}"]`).trigger('click')
expect(readerScroll).toHaveBeenLastCalledWith(expect.objectContaining({ top, behavior: 'auto' }))
expect(reader.scrollTop).toBe(top)
expect(currentEpisode()).toBe(episode)
}
expect(pageScroll).not.toHaveBeenCalled()
})
it('正文双向滚动同步当前集,目录项离开可见区时自动露出', async () => {
const { reader, directory, directoryScroll } = await mountReader()
reader.scrollTop = 1450
reader.dispatchEvent(new Event('scroll'))
await nextTick()
expect(currentEpisode()).toBe(3)
expect(directoryScroll).toHaveBeenCalled()
expect(directory.scrollTop).toBeGreaterThan(0)
reader.scrollTop = 0
reader.dispatchEvent(new Event('scroll'))
await nextTick()
expect(currentEpisode()).toBe(1)
expect(directory.scrollTop).toBe(0)
})
it('同集内阅读不反复挪动目录,也不因目录滚动而改变正文', async () => {
const { reader, directory, directoryScroll, readerScroll } = await mountReader()
directory.scrollTop = 60
directory.dispatchEvent(new Event('scroll'))
reader.scrollTop = 120
reader.dispatchEvent(new Event('scroll'))
await nextTick()
expect(currentEpisode()).toBe(1)
expect(directory.scrollTop).toBe(60)
expect(directoryScroll).not.toHaveBeenCalled()
expect(readerScroll).not.toHaveBeenCalled()
})
it('后台刷新与追加剧集不销毁正文或跳回首集,新增集可以定位', async () => {
const { reader, tops } = await mountReader()
await wrapper!.get('.reader-episode-link[data-episode="2"]').trigger('click')
const chapter = wrapper!.get('.reader-chapter[data-episode="2"]').element
tops[4] = 2200
await wrapper!.setProps({
episodes: [...episodes.map(item => ({ ...item })), { episode: 4, title: '回家', content: '结局' }]
})
expect(reader.scrollTop).toBe(668)
expect(currentEpisode()).toBe(2)
expect(wrapper!.get('.reader-chapter[data-episode="2"]').element).toBe(chapter)
await wrapper!.get('.reader-episode-link[data-episode="4"]').trigger('click')
expect(reader.scrollTop).toBe(2168)
expect(currentEpisode()).toBe(4)
})
it('内容尺寸和可见性变化重新测量,卸载释放尺寸监听', async () => {
const observers: TestObserver[] = []
/** 仅模拟尺寸通知;断言仍走真实组件挂载与卸载生命周期。 */
class TestObserver {
targets = new Set<Element>()
constructor(readonly callback: ResizeObserverCallback) {
observers.push(this)
}
observe(target: Element) {
this.targets.add(target)
}
unobserve(target: Element) {
this.targets.delete(target)
}
disconnect = vi.fn<() => void>(() => this.targets.clear())
notify() {
this.callback([], this as unknown as ResizeObserver)
}
}
vi.stubGlobal('ResizeObserver', TestObserver)
const { reader, tops } = await mountReader()
const observer = observers.find(item => item.targets.has(wrapper!.get('.reader-content').element))!
expect(observer).toBeDefined()
await wrapper!.get('.reader-episode-link[data-episode="2"]').trigger('click')
Object.defineProperty(reader, 'clientHeight', { configurable: true, value: 0 })
observer.notify()
await nextTick()
expect(currentEpisode()).toBe(2)
Object.defineProperty(reader, 'clientHeight', { configurable: true, value: 500 })
tops[2] = 500
observer.notify()
await nextTick()
expect(wrapper!.get('.reader-content').attributes('style')).toContain('--reader-height: 500px')
await wrapper!.get('.reader-episode-link[data-episode="2"]').trigger('click')
expect(reader.scrollTop).toBe(468)
wrapper!.unmount()
wrapper = undefined
expect(observer.disconnect).toHaveBeenCalledOnce()
})
})