R1 追加契约:学习端把新建与追加拆成两个请求体,追加不再发送 language(后端严格 解码会拒绝未知字段,此前真实追加返回 400);补充断言真实请求格式的回归测试。 R2 运行期恢复:启动恢复与运行期清扫合并为一处,worker 每秒把停留超过 15 秒的 processing 任务重新入队,超过 5 次尝试的任务置为 failed(attempts_exhausted); 人工重试重置尝试次数;日志如实区分“已入队”与“等待下一次清扫”。 R3 离页作废在途请求:closeBook/closeChapter 推进请求序号并清理 loading,导入页 在卸载后的成功响应不再触发跳转。 R4 重试自愈:重试被接受后先应用返回的 pending 状态并继续轮询,静默刷新失败不再 让页面停在处理失败。 测试:Go 20 个顶层用例通过(新增 2 项);学习端 38 项通过,其中 7 项在整改前代码 上实际失败;真实联调验证追加可用、被中断任务约 0.5 秒内在运行中自动恢复、重试在 首次刷新失败后自动显示最终结果。 文档:Wiki 先写后回读(架构、业务规则、本地验证),导出核心镜像。
266 lines
11 KiB
TypeScript
266 lines
11 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { flushPromises, mount } from '@vue/test-utils'
|
|
import { createPinia, setActivePinia } from 'pinia'
|
|
import { createMemoryHistory, createRouter, type Router } from 'vue-router'
|
|
import { defineComponent, h } from 'vue'
|
|
import BookView from '../views/BookView.vue'
|
|
import ImportView from '../views/ImportView.vue'
|
|
import ReaderView from '../views/ReaderView.vue'
|
|
import { useLibraryStore, type ChapterStatus } from '../stores/library'
|
|
import { useSessionStore } from '../stores/session'
|
|
|
|
// All accounts, books and texts in these tests are deliberately fictitious.
|
|
const user = { id: 42, username: 'fictional-reader', role: 'learner' as const }
|
|
const book = { id: 1, title: '虚构样例书', language: 'en' }
|
|
const navigation = { previousChapterId: null, nextChapterId: null }
|
|
const pasted = 'First line.\n\tIndented line.\nTwo spaces kept.\n\nLast line.\n'
|
|
|
|
const ok = (data: unknown) => new Response(JSON.stringify({ code: 200, data }), { status: 200 })
|
|
const created = (data: unknown) => new Response(JSON.stringify({ code: 200, data }), { status: 201 })
|
|
|
|
function chapter(status: ChapterStatus, extra: Record<string, unknown> = {}) {
|
|
return {
|
|
id: 55,
|
|
bookId: 1,
|
|
ordinal: 1,
|
|
title: '第一篇',
|
|
status,
|
|
charCount: 120,
|
|
errorReason: '',
|
|
errorMessage: '',
|
|
jobId: 7,
|
|
createdAt: '2026-01-01T00:00:00Z',
|
|
updatedAt: '2026-01-01T00:00:00Z',
|
|
...extra,
|
|
}
|
|
}
|
|
|
|
const stub = (name: string) => defineComponent({ name, render: () => h('div') })
|
|
|
|
async function viewAt(path: string): Promise<Router> {
|
|
const router = createRouter({
|
|
history: createMemoryHistory(),
|
|
routes: [
|
|
{ path: '/', component: stub('LibraryStub') },
|
|
{ path: '/import', component: stub('ImportStub') },
|
|
{ path: '/books/:id', component: stub('BookStub') },
|
|
{ path: '/chapters/:id', component: stub('ChapterStub') },
|
|
],
|
|
})
|
|
await router.push(path)
|
|
await router.isReady()
|
|
return router
|
|
}
|
|
|
|
function signIn() {
|
|
useSessionStore().user = { ...user }
|
|
}
|
|
|
|
describe('learner reading views', () => {
|
|
beforeEach(() => {
|
|
sessionStorage.clear()
|
|
setActivePinia(createPinia())
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
afterEach(() => {
|
|
useLibraryStore().stopPolling()
|
|
})
|
|
|
|
it('shows inline validation and sends nothing for an empty import form', async () => {
|
|
const fetchMock = vi.spyOn(globalThis, 'fetch')
|
|
signIn()
|
|
const router = await viewAt('/import')
|
|
const wrapper = mount(ImportView, { global: { plugins: [router] } })
|
|
await flushPromises()
|
|
|
|
await wrapper.find('form').trigger('submit')
|
|
expect(wrapper.text()).toContain('请填写标题。')
|
|
expect(wrapper.text()).toContain('请粘贴要导入的英文正文。')
|
|
expect(fetchMock).not.toHaveBeenCalled()
|
|
|
|
await wrapper.find('input#title').setValue(' ')
|
|
await wrapper.find('textarea#text').setValue(' \n\t ')
|
|
await wrapper.find('form').trigger('submit')
|
|
expect(wrapper.text()).toContain('请填写标题。')
|
|
expect(wrapper.text()).toContain('请粘贴要导入的英文正文。')
|
|
|
|
await wrapper.find('input#title').setValue('虚构样例第一章')
|
|
await wrapper.find('form').trigger('submit')
|
|
expect(wrapper.text()).not.toContain('请填写标题。')
|
|
expect(wrapper.text()).toContain('请粘贴要导入的英文正文。')
|
|
expect(fetchMock).not.toHaveBeenCalled()
|
|
|
|
wrapper.unmount()
|
|
})
|
|
|
|
it('submits valid pasted text and opens the created book', async () => {
|
|
const fetchMock = vi.spyOn(globalThis, 'fetch')
|
|
.mockResolvedValueOnce(created({ book, chapter: chapter('pending'), job: { id: 7 }, duplicate: false }))
|
|
signIn()
|
|
const router = await viewAt('/import')
|
|
const wrapper = mount(ImportView, { global: { plugins: [router] } })
|
|
// Element Plus assigns the input ids on mount, so wait for the first update.
|
|
await flushPromises()
|
|
|
|
await wrapper.find('input#title').setValue('虚构样例第一章')
|
|
await wrapper.find('textarea#text').setValue(pasted)
|
|
await wrapper.find('form').trigger('submit')
|
|
await flushPromises()
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(1)
|
|
expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toMatchObject({ title: '虚构样例第一章', text: pasted, language: 'en' })
|
|
expect(router.currentRoute.value.path).toBe('/books/1')
|
|
wrapper.unmount()
|
|
})
|
|
|
|
it('renders a ready chapter verbatim, keeping line breaks, tabs and repeated spaces', async () => {
|
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue(ok({ book, chapter: chapter('ready', { originalText: pasted, contentSha256: 'fictional' }), navigation }))
|
|
signIn()
|
|
const router = await viewAt('/chapters/55')
|
|
const wrapper = mount(ReaderView, { global: { plugins: [router] } })
|
|
await flushPromises()
|
|
|
|
const article = wrapper.find('.reader-text')
|
|
expect(article.exists()).toBe(true)
|
|
expect(article.element.textContent).toBe(pasted)
|
|
expect(wrapper.text()).toContain('已就绪')
|
|
wrapper.unmount()
|
|
})
|
|
|
|
it('shows the failure message of a failed chapter and no text', async () => {
|
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue(ok({ book, chapter: chapter('failed', { errorReason: 'decode_error', errorMessage: '无法解析正文,请检查编码。', jobId: null }), navigation }))
|
|
signIn()
|
|
const router = await viewAt('/chapters/55')
|
|
const wrapper = mount(ReaderView, { global: { plugins: [router] } })
|
|
await flushPromises()
|
|
|
|
expect(wrapper.text()).toContain('处理失败')
|
|
expect(wrapper.text()).toContain('无法解析正文,请检查编码。')
|
|
expect(wrapper.find('.reader-text').exists()).toBe(false)
|
|
// A null job id means there is nothing to retry yet.
|
|
expect(wrapper.text()).toContain('这一章暂时没有可重试的任务编号。')
|
|
wrapper.unmount()
|
|
})
|
|
|
|
it('offers retry on a failed chapter through its own job id', async () => {
|
|
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(ok({ book, chapter: chapter('failed', { errorMessage: '解析失败。', jobId: 7 }), navigation }))
|
|
signIn()
|
|
const router = await viewAt('/chapters/55')
|
|
const wrapper = mount(ReaderView, { global: { plugins: [router] } })
|
|
await flushPromises()
|
|
|
|
expect(wrapper.text()).toContain('解析失败。')
|
|
const retryButton = wrapper.get('.notice .el-button')
|
|
expect(retryButton.text()).toContain('重试处理')
|
|
await retryButton.trigger('click')
|
|
await flushPromises()
|
|
|
|
// No prior submit in this session: the job id comes from the chapter payload.
|
|
const retryCall = fetchMock.mock.calls.find(([input]) => String(input).endsWith('/jobs/7/retry'))
|
|
expect(retryCall?.[1]?.method).toBe('POST')
|
|
wrapper.unmount()
|
|
})
|
|
|
|
it('lists chapters with their status labels and links only ready chapters', async () => {
|
|
const chapters = [
|
|
chapter('pending', { id: 55, ordinal: 1, title: '第一篇', jobId: 7 }),
|
|
chapter('processing', { id: 56, ordinal: 2, title: '第二篇', jobId: 8 }),
|
|
chapter('ready', { id: 57, ordinal: 3, title: '第三篇', jobId: 9 }),
|
|
chapter('failed', { id: 58, ordinal: 4, title: '第四篇', errorMessage: '解析失败。', jobId: null }),
|
|
chapter('failed', { id: 59, ordinal: 5, title: '第五篇', errorMessage: '编码错误。', jobId: 10 }),
|
|
]
|
|
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(input => {
|
|
if (String(input).endsWith('/jobs/10/retry')) {
|
|
return Promise.resolve(ok({ job: { id: 10, status: 'pending', attempts: 1 }, chapter: { id: 59, bookId: 1, jobId: 10 } }))
|
|
}
|
|
return Promise.resolve(ok({ book, chapters }))
|
|
})
|
|
signIn()
|
|
const router = await viewAt('/books/1')
|
|
const wrapper = mount(BookView, { global: { plugins: [router] } })
|
|
await flushPromises()
|
|
|
|
const text = wrapper.text()
|
|
expect(text).toContain('待处理')
|
|
expect(text).toContain('处理中')
|
|
expect(text).toContain('已就绪')
|
|
expect(text).toContain('处理失败')
|
|
expect(text).toContain('解析失败。')
|
|
expect(text).toContain('编码错误。')
|
|
expect(wrapper.find('a[href="/chapters/57"]').exists()).toBe(true)
|
|
expect(wrapper.find('a[href="/chapters/55"]').exists()).toBe(false)
|
|
|
|
// Retry is offered only for the failed chapter that carries a job id.
|
|
const retryButtons = wrapper.findAll('.chapter-row .el-button').filter(button => button.text().includes('重试'))
|
|
expect(retryButtons).toHaveLength(1)
|
|
await retryButtons[0]!.trigger('click')
|
|
await flushPromises()
|
|
const retryCall = fetchMock.mock.calls.find(([input]) => String(input).endsWith('/jobs/10/retry'))
|
|
expect(retryCall?.[1]?.method).toBe('POST')
|
|
wrapper.unmount()
|
|
})
|
|
|
|
// Regression R1: appending must not send the language field that the append contract rejects.
|
|
it('appends to an existing book through the chapter endpoint without a language field', async () => {
|
|
const fetchMock = vi.spyOn(globalThis, 'fetch')
|
|
.mockResolvedValueOnce(ok({
|
|
items: [{
|
|
...book,
|
|
chapterCount: 1,
|
|
pendingCount: 0,
|
|
processingCount: 0,
|
|
readyCount: 1,
|
|
failedCount: 0,
|
|
createdAt: '2026-01-01T00:00:00Z',
|
|
updatedAt: '2026-01-01T00:00:00Z',
|
|
}],
|
|
}))
|
|
.mockResolvedValueOnce(created({ chapter: chapter('pending', { id: 56, ordinal: 2 }), job: { id: 8 } }))
|
|
signIn()
|
|
const router = await viewAt('/import?book=1')
|
|
const wrapper = mount(ImportView, { global: { plugins: [router] } })
|
|
await flushPromises()
|
|
|
|
await wrapper.find('input#title').setValue('第二篇')
|
|
await wrapper.find('textarea#text').setValue(pasted)
|
|
await wrapper.find('form').trigger('submit')
|
|
await flushPromises()
|
|
|
|
const appendCall = fetchMock.mock.calls.find(([input]) => String(input).endsWith('/books/1/chapters'))
|
|
expect(appendCall?.[1]?.method).toBe('POST')
|
|
const body = JSON.parse(String(appendCall?.[1]?.body)) as Record<string, unknown>
|
|
expect(body).toEqual({ requestId: expect.any(String), title: '第二篇', text: pasted })
|
|
expect(body).not.toHaveProperty('language')
|
|
expect(router.currentRoute.value.path).toBe('/books/1')
|
|
wrapper.unmount()
|
|
})
|
|
|
|
// Regression R3: a submit that finishes after the user left the page must not navigate back.
|
|
it('does not navigate after the user left the import page during a submit', async () => {
|
|
let finish!: (response: Response) => void
|
|
const fetchMock = vi.spyOn(globalThis, 'fetch')
|
|
.mockImplementationOnce(() => new Promise<Response>(resolve => { finish = resolve }))
|
|
signIn()
|
|
const router = await viewAt('/import')
|
|
const wrapper = mount(ImportView, { global: { plugins: [router] } })
|
|
await flushPromises()
|
|
|
|
await wrapper.find('input#title').setValue('虚构样例第一章')
|
|
await wrapper.find('textarea#text').setValue(pasted)
|
|
await wrapper.find('form').trigger('submit')
|
|
await flushPromises()
|
|
expect(fetchMock).toHaveBeenCalledTimes(1)
|
|
|
|
// The user leaves the page while the request is still open.
|
|
wrapper.unmount()
|
|
await router.push('/')
|
|
await flushPromises()
|
|
|
|
finish(created({ book, chapter: chapter('pending'), job: { id: 7 } }))
|
|
await flushPromises()
|
|
|
|
expect(router.currentRoute.value.path).toBe('/')
|
|
})
|
|
})
|