198 lines
9.8 KiB
TypeScript
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()
|
|
})
|
|
})
|