新增「从书籍生成」流程:用户粘贴教材目录(多行)并上传内含各章
.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>
309 lines
11 KiB
TypeScript
309 lines
11 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'
|
|
import { Hono } from 'hono'
|
|
import { createGenerateRouter } from './generate'
|
|
|
|
describe('generate route', () => {
|
|
let originalFetch: typeof fetch
|
|
|
|
beforeEach(() => {
|
|
originalFetch = globalThis.fetch
|
|
})
|
|
|
|
afterEach(() => {
|
|
globalThis.fetch = originalFetch
|
|
})
|
|
|
|
it('returns 400 when topic is missing', async () => {
|
|
const app = new Hono().route('/api/generate', createGenerateRouter('test-key'))
|
|
|
|
const res = await app.request('/api/generate', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({}),
|
|
})
|
|
|
|
expect(res.status).toBe(400)
|
|
})
|
|
|
|
it('returns 500 when DEEPSEEK_API_KEY is not configured', async () => {
|
|
const app = new Hono().route('/api/generate', createGenerateRouter(undefined))
|
|
|
|
const res = await app.request('/api/generate', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ topic: 'CSS 弹性布局' }),
|
|
})
|
|
|
|
expect(res.status).toBe(500)
|
|
})
|
|
|
|
it('returns parsed markdown on success', async () => {
|
|
globalThis.fetch = mock(async () =>
|
|
new Response(
|
|
JSON.stringify({ choices: [{ message: { content: '# CSS 弹性布局 教学设计' } }] }),
|
|
{ status: 200 },
|
|
),
|
|
) as unknown as typeof fetch
|
|
|
|
const app = new Hono().route('/api/generate', createGenerateRouter('test-key'))
|
|
const res = await app.request('/api/generate', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ topic: 'CSS 弹性布局' }),
|
|
})
|
|
|
|
expect(res.status).toBe(200)
|
|
const body = (await res.json()) as { filename: string; markdown: string }
|
|
expect(body.filename).toBe('CSS 弹性布局.md')
|
|
expect(body.markdown).toContain('# CSS 弹性布局 教学设计')
|
|
})
|
|
|
|
it('returns 502 when Deepseek responds with an error status', async () => {
|
|
globalThis.fetch = mock(async () => new Response('', { status: 401 })) as unknown as typeof fetch
|
|
|
|
const app = new Hono().route('/api/generate', createGenerateRouter('test-key'))
|
|
const res = await app.request('/api/generate', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ topic: 'CSS 弹性布局' }),
|
|
})
|
|
|
|
expect(res.status).toBe(502)
|
|
})
|
|
|
|
it('returns 502 when fetch throws', async () => {
|
|
globalThis.fetch = mock(async () => {
|
|
throw new Error('network error')
|
|
}) as unknown as typeof fetch
|
|
|
|
const app = new Hono().route('/api/generate', createGenerateRouter('test-key'))
|
|
const res = await app.request('/api/generate', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ topic: 'CSS 弹性布局' }),
|
|
})
|
|
|
|
expect(res.status).toBe(502)
|
|
})
|
|
|
|
it('returns 400 when theme is missing on outline', async () => {
|
|
const app = new Hono().route('/api/generate', createGenerateRouter('test-key'))
|
|
|
|
const res = await app.request('/api/generate/outline', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({}),
|
|
})
|
|
|
|
expect(res.status).toBe(400)
|
|
})
|
|
|
|
it('uses the requested count in the outline prompt', async () => {
|
|
let sentBody = ''
|
|
globalThis.fetch = mock(async (_url: unknown, init: { body?: string } = {}) => {
|
|
sentBody = init.body ?? ''
|
|
return new Response(
|
|
JSON.stringify({ choices: [{ message: { content: 'A\nB\nC' } }] }),
|
|
{ status: 200 },
|
|
)
|
|
}) as unknown as typeof fetch
|
|
|
|
const app = new Hono().route('/api/generate', createGenerateRouter('test-key'))
|
|
const res = await app.request('/api/generate/outline', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ theme: 'Web 前端', count: 25 }),
|
|
})
|
|
|
|
expect(res.status).toBe(200)
|
|
expect(sentBody).toContain('共约25条')
|
|
const body = (await res.json()) as { titles: string[] }
|
|
expect(body.titles).toEqual(['A', 'B', 'C'])
|
|
})
|
|
|
|
it('defaults the outline count to 18 and clamps out-of-range values', async () => {
|
|
const captured: string[] = []
|
|
globalThis.fetch = mock(async (_url: unknown, init: { body?: string } = {}) => {
|
|
captured.push(init.body ?? '')
|
|
return new Response(
|
|
JSON.stringify({ choices: [{ message: { content: 'A' } }] }),
|
|
{ status: 200 },
|
|
)
|
|
}) as unknown as typeof fetch
|
|
|
|
const app = new Hono().route('/api/generate', createGenerateRouter('test-key'))
|
|
|
|
await app.request('/api/generate/outline', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ theme: 'Web 前端' }),
|
|
})
|
|
await app.request('/api/generate/outline', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ theme: 'Web 前端', count: 999 }),
|
|
})
|
|
|
|
expect(captured[0]).toContain('共约18条')
|
|
expect(captured[1]).toContain('共约50条')
|
|
})
|
|
|
|
it('returns 502 when Deepseek response has no content', async () => {
|
|
globalThis.fetch = mock(async () => new Response(JSON.stringify({ choices: [] }), { status: 200 })) as unknown as typeof fetch
|
|
|
|
const app = new Hono().route('/api/generate', createGenerateRouter('test-key'))
|
|
const res = await app.request('/api/generate', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ topic: 'CSS 弹性布局' }),
|
|
})
|
|
|
|
expect(res.status).toBe(502)
|
|
})
|
|
|
|
it('does not add content to the prompt when topic is sent alone', async () => {
|
|
let sentBody = ''
|
|
globalThis.fetch = mock(async (_url: unknown, init: { body?: string } = {}) => {
|
|
sentBody = init.body ?? ''
|
|
return new Response(
|
|
JSON.stringify({ choices: [{ message: { content: '# x 教学设计' } }] }),
|
|
{ status: 200 },
|
|
)
|
|
}) as unknown as typeof fetch
|
|
|
|
const app = new Hono().route('/api/generate', createGenerateRouter('test-key'))
|
|
await app.request('/api/generate', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ topic: 'CSS 弹性布局' }),
|
|
})
|
|
|
|
expect(sentBody).not.toContain('作为编写依据')
|
|
expect(sentBody).toContain('请围绕主题')
|
|
expect(sentBody).toContain('生成一份教案。')
|
|
})
|
|
|
|
it('injects chapter content into the prompt when content is provided', async () => {
|
|
let sentBody = ''
|
|
globalThis.fetch = mock(async (_url: unknown, init: { body?: string } = {}) => {
|
|
sentBody = init.body ?? ''
|
|
return new Response(
|
|
JSON.stringify({ choices: [{ message: { content: '# x 教学设计' } }] }),
|
|
{ status: 200 },
|
|
)
|
|
}) as unknown as typeof fetch
|
|
|
|
const app = new Hono().route('/api/generate', createGenerateRouter('test-key'))
|
|
const res = await app.request('/api/generate', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ topic: 'CSS 弹性布局', content: '弹性盒模型的正文内容片段' }),
|
|
})
|
|
|
|
expect(res.status).toBe(200)
|
|
expect(sentBody).toContain('作为编写依据')
|
|
expect(sentBody).toContain('弹性盒模型的正文内容片段')
|
|
})
|
|
|
|
it('returns 400 when entries are missing on lessons-from-book', async () => {
|
|
const app = new Hono().route('/api/generate', createGenerateRouter('test-key'))
|
|
|
|
const res = await app.request('/api/generate/lessons-from-book', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({}),
|
|
})
|
|
|
|
expect(res.status).toBe(400)
|
|
})
|
|
|
|
it('parses lessons with sourceIndexes from lessons-from-book', async () => {
|
|
globalThis.fetch = mock(async () =>
|
|
new Response(
|
|
JSON.stringify({
|
|
choices: [
|
|
{
|
|
message: {
|
|
content:
|
|
'{"lessons":[{"title":"C# 入门——搭建环境","sourceIndexes":[0]},{"title":"变量与类型","sourceIndexes":[1,2]}]}',
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
{ status: 200 },
|
|
),
|
|
) as unknown as typeof fetch
|
|
|
|
const app = new Hono().route('/api/generate', createGenerateRouter('test-key'))
|
|
const res = await app.request('/api/generate/lessons-from-book', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
entries: [
|
|
{ index: 0, title: '第1章 C# 入门', charCount: 5000 },
|
|
{ index: 1, title: '第2章 变量', charCount: 3000 },
|
|
{ index: 2, title: '第3章 类型', charCount: 2000 },
|
|
],
|
|
}),
|
|
})
|
|
|
|
expect(res.status).toBe(200)
|
|
const body = (await res.json()) as {
|
|
lessons: { title: string; sourceIndexes: number[] }[]
|
|
}
|
|
expect(body.lessons).toEqual([
|
|
{ title: 'C# 入门——搭建环境', sourceIndexes: [0] },
|
|
{ title: '变量与类型', sourceIndexes: [1, 2] },
|
|
])
|
|
})
|
|
|
|
it('strips code fences and drops out-of-range sourceIndexes on lessons-from-book', async () => {
|
|
globalThis.fetch = mock(async () =>
|
|
new Response(
|
|
JSON.stringify({
|
|
choices: [
|
|
{
|
|
message: {
|
|
content:
|
|
'```json\n{"lessons":[{"title":"课时一","sourceIndexes":[0,9]}]}\n```',
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
{ status: 200 },
|
|
),
|
|
) as unknown as typeof fetch
|
|
|
|
const app = new Hono().route('/api/generate', createGenerateRouter('test-key'))
|
|
const res = await app.request('/api/generate/lessons-from-book', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ entries: [{ index: 0, title: '第1章', charCount: 100 }] }),
|
|
})
|
|
|
|
expect(res.status).toBe(200)
|
|
const body = (await res.json()) as {
|
|
lessons: { title: string; sourceIndexes: number[] }[]
|
|
}
|
|
expect(body.lessons).toEqual([{ title: '课时一', sourceIndexes: [0] }])
|
|
})
|
|
|
|
it('returns 502 when lessons-from-book JSON is malformed', async () => {
|
|
globalThis.fetch = mock(async () =>
|
|
new Response(
|
|
JSON.stringify({ choices: [{ message: { content: '这不是 JSON' } }] }),
|
|
{ status: 200 },
|
|
),
|
|
) as unknown as typeof fetch
|
|
|
|
const app = new Hono().route('/api/generate', createGenerateRouter('test-key'))
|
|
const res = await app.request('/api/generate/lessons-from-book', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ entries: [{ index: 0, title: '第1章', charCount: 100 }] }),
|
|
})
|
|
|
|
expect(res.status).toBe(502)
|
|
})
|
|
})
|