feat: add concurrent batch lesson generation

This commit is contained in:
2026-06-16 07:33:29 -06:00
parent 2321b5f68e
commit 8b0e4df43d
3 changed files with 179 additions and 9 deletions

View File

@@ -2,6 +2,7 @@ import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createEmptyBook, createEmptyTeachingDesign } from '../domain/teachingDesign'
import * as booksApi from '../services/booksApi'
import BatchGenerateDialog from './BatchGenerateDialog.vue'
import GenerateLessonDialog from './GenerateLessonDialog.vue'
import WorkspaceView from './WorkspaceView.vue'
@@ -16,6 +17,32 @@ function mockBook(data = createEmptyBook()): void {
})
}
function deferred<T>(): {
promise: Promise<T>
resolve: (value: T) => void
reject: (reason?: unknown) => void
} {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((promiseResolve, promiseReject) => {
resolve = promiseResolve
reject = promiseReject
})
return { promise, resolve, reject }
}
function generatedLesson(topic: string): booksApi.GenerateResult {
return {
filename: `${topic}.md`,
markdown: [
`# ${topic} 教学设计`,
'|:---|:---|',
`| **课题** | **${topic}** |`,
'| **课时** | 1课时40分钟 |',
].join('\n'),
}
}
describe('WorkspaceView', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -87,6 +114,68 @@ describe('WorkspaceView', () => {
expect(wrapper.text()).not.toContain('封面')
})
it('batch generates up to three lessons concurrently and keeps outline order', async () => {
mockBook()
const requests = new Map<string, ReturnType<typeof deferred<booksApi.GenerateResult>>>()
vi.mocked(booksApi.generateLesson).mockImplementation((topic) => {
const request = deferred<booksApi.GenerateResult>()
requests.set(topic, request)
return request.promise
})
const wrapper = mount(WorkspaceView, { props: { bookId: 'b1' } })
await flushPromises()
await wrapper.get('[data-testid="batch-generate"]').trigger('click')
wrapper.getComponent(BatchGenerateDialog).vm.$emit('start', [
'第一课',
'第二课',
'第三课',
'第四课',
'第五课',
])
await flushPromises()
expect(booksApi.generateLesson).toHaveBeenCalledTimes(3)
expect(booksApi.generateLesson).toHaveBeenNthCalledWith(1, '第一课')
expect(booksApi.generateLesson).toHaveBeenNthCalledWith(2, '第二课')
expect(booksApi.generateLesson).toHaveBeenNthCalledWith(3, '第三课')
requests.get('第三课')!.resolve(generatedLesson('第三课'))
await flushPromises()
expect(booksApi.generateLesson).toHaveBeenCalledTimes(4)
expect(booksApi.generateLesson).toHaveBeenNthCalledWith(4, '第四课')
expect(wrapper.findAll('.lesson-sidebar-topic').map((node) => node.text())).toEqual([])
requests.get('第四课')!.resolve(generatedLesson('第四课'))
await flushPromises()
expect(booksApi.generateLesson).toHaveBeenCalledTimes(5)
expect(booksApi.generateLesson).toHaveBeenNthCalledWith(5, '第五课')
requests.get('第一课')!.resolve(generatedLesson('第一课'))
await flushPromises()
expect(wrapper.findAll('.lesson-sidebar-topic').map((node) => node.text())).toEqual(['第一课'])
requests.get('第二课')!.resolve(generatedLesson('第二课'))
await flushPromises()
expect(wrapper.findAll('.lesson-sidebar-topic').map((node) => node.text())).toEqual([
'第一课',
'第二课',
'第三课',
'第四课',
])
requests.get('第五课')!.resolve(generatedLesson('第五课'))
await flushPromises()
expect(wrapper.findAll('.lesson-sidebar-topic').map((node) => node.text())).toEqual([
'第一课',
'第二课',
'第三课',
'第四课',
'第五课',
])
})
it('clears the lessons after confirmation', async () => {
const data = createEmptyBook()
data.designs.push(createEmptyTeachingDesign('1.md'))

View File

@@ -13,6 +13,8 @@ import PrintBook from './PrintBook.vue'
import UploadDropzone from './UploadDropzone.vue'
import WorkspaceToolbar from './WorkspaceToolbar.vue'
const BATCH_GENERATE_CONCURRENCY = 3
const props = defineProps<{ bookId: string }>()
defineEmits<{ back: [] }>()
@@ -35,6 +37,7 @@ const {
updateDesign,
clearBook,
generateLesson,
generateLessons,
regenerateLesson,
} = useTeachingBook(props.bookId)
@@ -153,15 +156,19 @@ async function handleBatchStart(topics: string[]): Promise<void> {
batchTotal.value = topics.length
batchError.value = null
for (const topic of topics) {
if (batchCancelled.value) break
batchCurrentTopic.value = topic
const result = await generateLesson(topic)
if (!result.ok) {
batchError.value = result.message
break
}
batchDone.value++
const result = await generateLessons(topics, {
concurrency: BATCH_GENERATE_CONCURRENCY,
isCancelled: () => batchCancelled.value,
onTopicStart: (topic) => {
batchCurrentTopic.value = topic
},
onLessonComplete: (count) => {
batchDone.value += count
},
})
if (!result.ok) {
batchError.value = result.message
}
batchRunning.value = false