Files
teaching-design/src/services/booksApi.test.ts
yuetsh a427bba8c8 feat: 从书籍生成教案(粘贴目录 + 上传 md 压缩包)
新增「从书籍生成」流程:用户粘贴教材目录(多行)并上传内含各章
.md 的 ZIP(文件名=目录标题),前端用 JSZip 解压匹配,AI 按章节
体量划分课时,预览/编辑后批量生成,每篇以对应章节正文作为上下文。

服务端
- generate.ts: 新增 POST /lessons-from-book(目录条目→AI 划分课时
  JSON,剥围栏/校验/丢非法行);扩展 POST / 支持可选 content 上下文,
  无 content 时请求体不变(向后兼容)。

前端
- bookImport.ts: normalizeTitle / parseZip / matchToc / assembleContent。
- booksApi.ts: generateLesson 兼容签名 + divideLessonsFromBook。
- useTeachingBook.ts: 抽出 runGenerationPool,新增 generateLessonsFromBook。
- BookImportDialog.vue 多阶段对话框;UploadDropzone 加 accept/multiple;
  菜单串接「从书籍生成」。

顺带修复 5 个遗留失败测试:import 功能已于 fb0b8d1 删除但测试漏删,
清理孤儿测试;批量生成对齐为单参 generateLesson(topic)。

docs/book-import-spec.md 记录规格。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 05:07:19 -06:00

132 lines
5.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createEmptyBook } from '../../shared/domain/teachingDesign'
import * as booksApi from './booksApi'
describe('booksApi', () => {
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn())
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('lists books', async () => {
const summaries = [{ id: 'b1', name: 'Web', updatedAt: '2026-01-01T00:00:00.000Z', lessonCount: 2 }]
vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify(summaries), { status: 200 }))
await expect(booksApi.listBooks()).resolves.toEqual(summaries)
expect(fetch).toHaveBeenCalledWith('/api/books', expect.objectContaining({ headers: expect.any(Object) }))
})
it('creates a book', async () => {
const created = { id: 'b1', name: '新整本', updatedAt: '2026-01-01T00:00:00.000Z', data: createEmptyBook() }
vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify(created), { status: 200 }))
await expect(booksApi.createBook('新整本')).resolves.toEqual(created)
const [, init] = vi.mocked(fetch).mock.calls[0]!
expect(init?.method).toBe('POST')
expect(JSON.parse(init?.body as string)).toEqual({ name: '新整本' })
})
it('gets a book', async () => {
const record = { id: 'b1', name: 'Web', updatedAt: '2026-01-01T00:00:00.000Z', data: createEmptyBook() }
vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify(record), { status: 200 }))
await expect(booksApi.getBook('b1')).resolves.toEqual(record)
expect(fetch).toHaveBeenCalledWith('/api/books/b1', expect.objectContaining({ headers: expect.any(Object) }))
})
it('updates a book', async () => {
const meta = { id: 'b1', name: 'Web', updatedAt: '2026-01-02T00:00:00.000Z' }
vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify(meta), { status: 200 }))
const data = createEmptyBook()
await expect(booksApi.updateBook('b1', data)).resolves.toEqual(meta)
const [, init] = vi.mocked(fetch).mock.calls[0]!
expect(init?.method).toBe('PUT')
expect(JSON.parse(init?.body as string)).toEqual({ data })
})
it('renames a book', async () => {
const meta = { id: 'b1', name: '新名称', updatedAt: '2026-01-01T00:00:00.000Z' }
vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify(meta), { status: 200 }))
await expect(booksApi.renameBook('b1', '新名称')).resolves.toEqual(meta)
const [, init] = vi.mocked(fetch).mock.calls[0]!
expect(init?.method).toBe('PATCH')
expect(JSON.parse(init?.body as string)).toEqual({ name: '新名称' })
})
it('deletes a book', async () => {
vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify({ ok: true }), { status: 200 }))
await expect(booksApi.deleteBook('b1')).resolves.toEqual({ ok: true })
const [, init] = vi.mocked(fetch).mock.calls[0]!
expect(init?.method).toBe('DELETE')
})
it('generates a lesson', async () => {
const result = { filename: 'css-flex.md', markdown: '# CSS 弹性布局 教学设计' }
vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify(result), { status: 200 }))
await expect(booksApi.generateLesson('CSS 弹性布局')).resolves.toEqual(result)
const [, init] = vi.mocked(fetch).mock.calls[0]!
expect(JSON.parse(init?.body as string)).toEqual({ topic: 'CSS 弹性布局' })
})
it('generates a lesson with chapter content as context', async () => {
const result = { filename: 'css-flex.md', markdown: '# CSS 弹性布局 教学设计' }
vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify(result), { status: 200 }))
await booksApi.generateLesson('CSS 弹性布局', { content: '弹性盒模型正文' })
const [, init] = vi.mocked(fetch).mock.calls[0]!
expect(JSON.parse(init?.body as string)).toEqual({
topic: 'CSS 弹性布局',
content: '弹性盒模型正文',
})
})
it('still accepts an AbortSignal as the second argument', async () => {
const result = { filename: 'x.md', markdown: '# x' }
vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify(result), { status: 200 }))
const controller = new AbortController()
await booksApi.generateLesson('x', controller.signal)
const [, init] = vi.mocked(fetch).mock.calls[0]!
expect(JSON.parse(init?.body as string)).toEqual({ topic: 'x' })
expect(init?.signal).toBe(controller.signal)
})
it('divides lessons from a book table of contents', async () => {
const lessons = [{ title: 'C# 入门——搭建环境', sourceIndexes: [0] }]
vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify({ lessons }), { status: 200 }))
const entries = [{ index: 0, title: '第1章 C# 入门', charCount: 5000 }]
await expect(booksApi.divideLessonsFromBook(entries)).resolves.toEqual({ lessons })
const [url, init] = vi.mocked(fetch).mock.calls[0]!
expect(url).toBe('/api/generate/lessons-from-book')
expect(JSON.parse(init?.body as string)).toEqual({ entries })
})
it('throws the server error message on failure', async () => {
vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify({ error: '整本不存在。' }), { status: 404 }))
await expect(booksApi.getBook('missing')).rejects.toThrow('整本不存在。')
})
it('throws a generic error when the response has no error message', async () => {
vi.mocked(fetch).mockResolvedValue(new Response('', { status: 500 }))
await expect(booksApi.getBook('b1')).rejects.toThrow('请求失败500')
})
})