Files
lexgo/learner/src/__tests__/reading.spec.ts
T
ila b18f9cc4a5 fix: 整改 #5 审核问题 R1~R4 (#5)
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 先写后回读(架构、业务规则、本地验证),导出核心镜像。
2026-09-11 11:02:32 +08:00

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('/')
})
})