diff --git a/frontend/src/composables/useReaderAutoPlayback.test.ts b/frontend/src/composables/useReaderAutoPlayback.test.ts new file mode 100644 index 0000000..d660b91 --- /dev/null +++ b/frontend/src/composables/useReaderAutoPlayback.test.ts @@ -0,0 +1,110 @@ +import { computed, ref } from 'vue' +import { describe, expect, it, vi } from 'vitest' +import { useReaderAutoPlayback } from './useReaderAutoPlayback' + +describe('useReaderAutoPlayback', () => { + it('starts speech when chapter HTML contains br elements without paragraphs', () => { + const chapterText = fakeElement('第一段\n第二段', [], '第一段
第二段') + const scrollContainer = fakeElement('') + const originalHtml = chapterText.innerHTML + const startTTS = vi.fn() + const store = { + speechConfig: { + provider: 'openai', + openaiRequestMode: 'chunked', + }, + currentIndex: 0, + hasNext: false, + hasPrev: false, + isPaused: false, + isAutoScrolling: false, + startTTS, + preloadOpenAITTS: vi.fn(), + stopTTS: vi.fn(), + } as unknown as Parameters[0] + + const playback = useReaderAutoPlayback( + store, + computed(() => ({ + autoPageMode: 'pixel', + clickAction: 'none', + scrollPixel: 1, + pageSpeed: 1_000, + fontSize: 16, + lineHeight: 1.5, + })), + computed(() => false), + ref(scrollContainer), + ref(chapterText), + vi.fn(), + vi.fn(), + ) + + playback.startSpeech() + + expect(startTTS).toHaveBeenCalledTimes(1) + expect(startTTS.mock.calls[0]?.[0]).toContain('第一段') + expect(chapterText.innerHTML).toBe(originalHtml) + }) + + it('keeps paragraph elements as separate speech targets when they exist', () => { + const firstParagraph = fakeElement('第一段') + const secondParagraph = fakeElement('第二段') + const chapterText = fakeElement('第一段\n第二段', [firstParagraph, secondParagraph]) + const scrollContainer = fakeElement('') + const startTTS = vi.fn() + const store = { + speechConfig: { + provider: 'system', + openaiRequestMode: 'chunked', + }, + currentIndex: 0, + hasNext: false, + hasPrev: false, + isPaused: false, + isAutoScrolling: false, + startTTS, + preloadOpenAITTS: vi.fn(), + stopTTS: vi.fn(), + } as unknown as Parameters[0] + + const playback = useReaderAutoPlayback( + store, + computed(() => ({ + autoPageMode: 'pixel', + clickAction: 'none', + scrollPixel: 1, + pageSpeed: 1_000, + fontSize: 16, + lineHeight: 1.5, + })), + computed(() => false), + ref(scrollContainer), + ref(chapterText), + vi.fn(), + vi.fn(), + ) + + playback.startSpeech() + + expect(startTTS).toHaveBeenCalledTimes(1) + expect(startTTS.mock.calls[0]?.[0]).toBe('第一段') + }) +}) + +function fakeElement(innerText: string, paragraphs: HTMLElement[] = [], innerHTML = '') { + return { + innerText, + innerHTML, + offsetTop: 0, + offsetHeight: 20, + scrollTop: 0, + classList: { + add: vi.fn(), + remove: vi.fn(), + }, + querySelector: vi.fn(() => null), + querySelectorAll: vi.fn((selector: string) => selector === 'p' ? paragraphs : []), + scrollTo: vi.fn(), + } as unknown as HTMLElement +} diff --git a/frontend/src/composables/useReaderAutoPlayback.ts b/frontend/src/composables/useReaderAutoPlayback.ts index 6ef24ec..b871ecc 100644 --- a/frontend/src/composables/useReaderAutoPlayback.ts +++ b/frontend/src/composables/useReaderAutoPlayback.ts @@ -54,7 +54,11 @@ export function useReaderAutoPlayback( ? Array.from(scrollContainerRef.value?.querySelectorAll('.chapter-text[data-role="continuous"]') || []) as HTMLElement[] : (chapterTextRef.value ? [chapterTextRef.value] : []) if (!roots.length) return [] as HTMLElement[] - const allElements = roots.flatMap((root) => Array.from(root.querySelectorAll('p')) as HTMLElement[]) + const allElements = roots.flatMap((root) => { + const paragraphs = Array.from(root.querySelectorAll('p')) as HTMLElement[] + if (paragraphs.length) return paragraphs + return root.innerText.trim() ? [root] : [] + }) const list: HTMLElement[] = [] let lastText = '' allElements.forEach((el) => {