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: '', 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('.reader-scroll .n-scrollbar-container').element const directory = wrapper.get('.reader-directory-scroll .n-scrollbar-container').element const tops: Record = { 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( /\.reader-chapter-heading\s*\{[^}]*position:\s*sticky;[^}]*top:\s*0;[^}]*z-index:\s*2;[^}]*background:\s*var\(--app-surface\)/ ) expect(css).toMatch( /\.reader-chapter-heading\s*\{[^}]*margin-inline:\s*calc\(var\(--reader-gutter\) \* -1\);[^}]*padding:\s*14px var\(--reader-gutter\) 18px;/ ) expect(css).toMatch(/@media \(max-width: 800px\)[\s\S]*?\.reader-content\s*\{[^}]*padding-inline:\s*18px/) expect(css).toMatch(/@media \(max-width: 800px\)[\s\S]*?\.reader-content\s*\{[^}]*--reader-gutter:\s*18px/) expect(css).toMatch(/@media \(max-width: 800px\)[\s\S]*?\.episode-reader\s*\{[^}]*72px/) }) 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('') 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() 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() }) })