Files
lexgo/learner/src/__tests__/library.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

446 lines
20 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import {
NOT_FOUND_MESSAGE,
POLL_INTERVAL_MS,
TEXT_MAX_CODE_POINTS,
canRetry,
statusSummary,
useLibraryStore,
type ChapterDetail,
} from '../stores/library'
import { useSessionStore } from '../stores/session'
// All accounts, books and texts in these tests are deliberately fictitious.
const user = { id: 7, username: 'fictional-reader', role: 'learner' as const }
const book = { id: 1, title: '虚构样例书', language: 'en' }
const navigation = { previousChapterId: null, nextChapterId: null }
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 })
const httpError = (status: number, msg: string) => new Response(JSON.stringify({ code: status, msg }), { status })
const chapter = (overrides: Partial<ChapterDetail> = {}): ChapterDetail => ({
id: 55,
bookId: 1,
ordinal: 1,
title: '第一篇',
status: 'pending',
charCount: 120,
errorReason: '',
errorMessage: '',
contentSha256: 'fictional-sha256',
jobId: 7,
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
...overrides,
})
const summary = (overrides: Partial<Record<string, number | string>> = {}) => ({
...book,
chapterCount: 0,
pendingCount: 0,
processingCount: 0,
readyCount: 0,
failedCount: 0,
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
...overrides,
})
const job = { id: 7, bookId: 1, chapterId: 55, status: 'pending', attempts: 0, errorReason: '', errorMessage: '', createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z' }
const fetchMock = () => vi.mocked(globalThis.fetch)
const paths = () => fetchMock().mock.calls.map(([input]) => String(input))
const bodyOf = (index: number): Record<string, unknown> => JSON.parse(String(fetchMock().mock.calls[index]?.[1]?.body)) as Record<string, unknown>
async function signIn() {
fetchMock().mockResolvedValueOnce(ok({ token: 'fictional-token', expiresAt: '2030-01-01', user }))
const session = useSessionStore()
await session.login(user.username, 'fictional-password')
return session
}
describe('learner library store', () => {
beforeEach(() => {
sessionStorage.clear()
setActivePinia(createPinia())
vi.restoreAllMocks()
// Any request a test did not expect fails loudly instead of hanging.
vi.spyOn(globalThis, 'fetch').mockImplementation(input => {
throw new Error(`unexpected request: ${String(input)}`)
})
})
afterEach(() => {
// Never let a polling timer outlive its test.
useLibraryStore().stopPolling()
vi.useRealTimers()
})
it('loads the library and summarises the count fields the API reports', async () => {
await signIn()
const library = useLibraryStore()
fetchMock().mockResolvedValueOnce(ok({ items: [summary({ chapterCount: 5, readyCount: 2, processingCount: 1, pendingCount: 1, failedCount: 1 })] }))
await library.loadBooks()
expect(library.books).toHaveLength(1)
expect(library.booksLoading).toBe(false)
expect(library.booksError).toBe('')
expect(statusSummary(library.books[0]!)).toBe('已就绪 2 · 处理中 1 · 待处理 1 · 失败 1')
expect(paths()).toEqual(['/api/v1/login', '/api/v1/books'])
expect(fetchMock().mock.calls[1]?.[1]?.headers).toMatchObject({ Authorization: 'Bearer fictional-token' })
})
it('shows pending and processing separately instead of deriving one from a total', async () => {
// processingCount is strictly "processing" now, so pendingCount must be read as given.
const queued = summary({ chapterCount: 3, readyCount: 1, processingCount: 0, pendingCount: 2, failedCount: 0 })
expect(statusSummary(queued)).toBe('已就绪 1 · 待处理 2')
expect(statusSummary(queued)).not.toContain('处理中')
const done = summary({ chapterCount: 1, readyCount: 1 })
expect(statusSummary(done)).toBe('已就绪 1')
expect(statusSummary(summary({ chapterCount: 0 }))).toBe('')
})
it('loads a book detail whose chapters carry the job id used for retry', async () => {
await signIn()
const library = useLibraryStore()
fetchMock().mockResolvedValueOnce(ok({ book, chapters: [chapter({ status: 'failed', jobId: 7 }), chapter({ id: 56, status: 'failed', jobId: null })] }))
await library.loadBook(1)
expect(library.book?.title).toBe('虚构样例书')
expect(library.chapters).toHaveLength(2)
expect(paths()[1]).toBe('/api/v1/books/1')
// The chapter itself carries the job id, even for a freshly loaded book.
expect(library.chapters[0]?.jobId).toBe(7)
expect(canRetry(library.chapters[0]!)).toBe(true)
// A null job id means the chapter has nothing to retry yet.
expect(canRetry(library.chapters[1]!)).toBe(false)
})
it('retries a failed chapter loaded fresh from the book detail, without any submit in this session', async () => {
vi.useFakeTimers()
await signIn()
const library = useLibraryStore()
// No submit() call: this is a plain reload, the old workaround would hide retry here.
fetchMock().mockResolvedValueOnce(ok({ book, chapters: [chapter({ status: 'failed', errorMessage: '无法解析正文。', jobId: 7 })] }))
await library.loadBook(1)
fetchMock()
.mockResolvedValueOnce(ok({ job: { ...job, status: 'pending', attempts: 1 }, chapter: { id: 55, bookId: 1, jobId: 7 } }))
.mockResolvedValueOnce(ok({ book, chapters: [chapter({ jobId: 7 })] }))
await library.retryChapter(55)
expect(paths()).toContain('/api/v1/jobs/7/retry')
expect(library.retryingChapterId).toBeNull()
expect(library.chapters[0]?.status).toBe('pending')
})
it('pastes a new book and reads the job id from the created chapter', async () => {
await signIn()
const library = useLibraryStore()
fetchMock().mockResolvedValueOnce(created({ book, chapter: chapter(), job, duplicate: false }))
const bookId = await library.submit({ title: ' 第一篇 ', text: 'Hello world.\nSecond line.', target: { mode: 'new' } })
expect(bookId).toBe(1)
expect(library.submitting).toBe(false)
expect(library.submitError).toBe('')
expect(paths()[1]).toBe('/api/v1/books')
expect(bodyOf(1)).toEqual({ requestId: expect.any(String), title: '第一篇', text: 'Hello world.\nSecond line.', language: 'en' })
})
it('appends to an existing book through the chapter endpoint', async () => {
await signIn()
const library = useLibraryStore()
fetchMock().mockResolvedValueOnce(created({ chapter: chapter(), job, duplicate: false }))
const bookId = await library.submit({ title: '第二篇', text: 'Another text.', target: { mode: 'append', bookId: 1 } })
expect(bookId).toBe(1)
expect(paths()[1]).toBe('/api/v1/books/1/chapters')
})
it('reuses one requestId while the same unsent content keeps failing', async () => {
await signIn()
const library = useLibraryStore()
const input = { title: '第一篇', text: 'Hello world.', target: { mode: 'new' as const } }
fetchMock()
.mockResolvedValueOnce(httpError(500, '服务器开小差了'))
.mockResolvedValueOnce(created({ book, chapter: chapter(), job, duplicate: false }))
await expect(library.submit(input)).rejects.toThrow('服务器开小差了')
expect(library.submitError).toBe('服务器开小差了')
await library.submit(input)
// One chapter, not two: the retry of unchanged content reuses the requestId.
expect(bodyOf(2).requestId).toBe(bodyOf(1).requestId)
})
it('uses a fresh requestId after a successful submit and after the content changes', async () => {
await signIn()
const library = useLibraryStore()
fetchMock()
.mockResolvedValueOnce(created({ book, chapter: chapter(), job, duplicate: false }))
.mockResolvedValueOnce(created({ book, chapter: chapter({ id: 56 }), job, duplicate: false }))
.mockResolvedValueOnce(created({ book, chapter: chapter({ id: 57 }), job, duplicate: false }))
await library.submit({ title: '第一篇', text: 'Hello world.', target: { mode: 'new' } })
await library.submit({ title: '第一篇', text: 'Hello world.', target: { mode: 'new' } })
await library.submit({ title: '第一篇', text: 'Hello world changed.', target: { mode: 'new' } })
expect(bodyOf(2).requestId).not.toBe(bodyOf(1).requestId)
expect(bodyOf(3).requestId).not.toBe(bodyOf(2).requestId)
})
it('rejects invalid input before sending anything', async () => {
await signIn()
const library = useLibraryStore()
const sent = paths().length
await expect(library.submit({ title: ' ', text: 'Hello.', target: { mode: 'new' } })).rejects.toThrow('请填写标题。')
await expect(library.submit({ title: '标题', text: ' \n\t ', target: { mode: 'new' } })).rejects.toThrow('请粘贴要导入的英文正文。')
await expect(library.submit({ title: 'x'.repeat(121), text: 'Hello.', target: { mode: 'new' } })).rejects.toThrow('标题不能超过 120 个字符。')
await expect(library.submit({ title: '标题', text: 'a'.repeat(TEXT_MAX_CODE_POINTS + 1), target: { mode: 'new' } })).rejects.toThrow(`正文不能超过 ${TEXT_MAX_CODE_POINTS} 个字符。`)
expect(paths().length).toBe(sent)
expect(library.submitError).toBe(`正文不能超过 ${TEXT_MAX_CODE_POINTS} 个字符。`)
})
it('measures the text limit in Unicode code points', async () => {
await signIn()
const library = useLibraryStore()
// 100000 astral characters are 200000 UTF-16 units but still within the limit.
fetchMock().mockResolvedValueOnce(created({ book, chapter: chapter(), job, duplicate: false }))
await expect(library.submit({ title: '标题', text: '😀'.repeat(TEXT_MAX_CODE_POINTS), target: { mode: 'new' } })).resolves.toBe(1)
expect([...('😀'.repeat(TEXT_MAX_CODE_POINTS))].length).toBe(TEXT_MAX_CODE_POINTS)
})
it('polls a pending chapter until it is ready and then stops', async () => {
vi.useFakeTimers()
await signIn()
const library = useLibraryStore()
fetchMock().mockResolvedValueOnce(ok({ book, chapter: chapter(), navigation }))
await library.loadChapter(55)
expect(library.chapter?.status).toBe('pending')
expect(library.readerText).toBe('')
fetchMock().mockResolvedValueOnce(ok({ book, chapter: chapter({ status: 'ready', originalText: 'Hello\nworld.' }), navigation }))
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS)
expect(library.chapter?.status).toBe('ready')
expect(library.readerText).toBe('Hello\nworld.')
const settled = paths().length
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 3)
expect(paths().length).toBe(settled)
})
it('retries a failed chapter through the chapter job id and resumes polling', async () => {
vi.useFakeTimers()
await signIn()
const library = useLibraryStore()
fetchMock().mockResolvedValueOnce(ok({ book, chapter: chapter({ status: 'failed', errorReason: 'decode_error', errorMessage: '无法解析正文。', jobId: 7 }), navigation }))
await library.loadChapter(55)
expect(library.chapter?.status).toBe('failed')
expect(canRetry(library.chapter!)).toBe(true)
fetchMock()
.mockResolvedValueOnce(ok({ job: { ...job, status: 'pending', attempts: 1 }, chapter: { id: 55, bookId: 1, jobId: 7 } }))
.mockResolvedValueOnce(ok({ book, chapter: chapter(), navigation }))
await library.retryChapter(55)
expect(paths()).toContain('/api/v1/jobs/7/retry')
expect(library.retryingChapterId).toBeNull()
expect(library.chapter?.status).toBe('pending')
// The retry restarts polling for the chapter it re-queued.
const before = paths().length
fetchMock().mockResolvedValueOnce(ok({ book, chapter: chapter({ status: 'ready', originalText: 'Hello world.' }), navigation }))
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS)
expect(paths().length).toBe(before + 1)
expect(library.readerText).toBe('Hello world.')
})
it('refuses to retry a chapter whose job id is null', async () => {
await signIn()
const library = useLibraryStore()
fetchMock().mockResolvedValueOnce(ok({ book, chapter: chapter({ status: 'failed', jobId: null }), navigation }))
await library.loadChapter(55)
expect(canRetry(library.chapter!)).toBe(false)
await expect(library.retryChapter(55)).rejects.toThrow('这一章暂时没有可重试的任务编号。')
expect(paths()).toEqual(['/api/v1/login', '/api/v1/chapters/55'])
})
it('reports another account id as 内容不存在 and stops polling for it', async () => {
vi.useFakeTimers()
await signIn()
const library = useLibraryStore()
fetchMock().mockResolvedValueOnce(httpError(404, 'chapter not found'))
await library.loadChapter(99)
expect(library.chapter).toBeNull()
expect(library.chapterError).toBe(NOT_FOUND_MESSAGE)
fetchMock().mockResolvedValueOnce(httpError(404, 'book not found'))
await library.loadBook(99)
expect(library.book).toBeNull()
expect(library.bookError).toBe(NOT_FOUND_MESSAGE)
const settled = paths().length
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 4)
expect(paths().length).toBe(settled)
})
it('stops polling and ignores a late response once the session is cleared', async () => {
vi.useFakeTimers()
const session = await signIn()
const library = useLibraryStore()
fetchMock().mockResolvedValueOnce(ok({ book, chapter: chapter(), navigation }))
await library.loadChapter(55)
expect(library.chapter).not.toBeNull()
let finish!: (response: Response) => void
fetchMock().mockImplementationOnce(() => new Promise<Response>(resolve => { finish = resolve }))
fetchMock().mockResolvedValueOnce(ok(null))
const late = library.loadChapter(55, { silent: true })
const logout = session.logout()
finish(ok({ book, chapter: chapter({ status: 'ready', originalText: 'Late text.' }), navigation }))
await late
await logout
expect(session.user).toBeNull()
expect(library.chapter).toBeNull()
expect(library.readerText).toBe('')
const settled = paths().length
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 4)
expect(paths().length).toBe(settled)
})
it('keeps newer state when an older request answers later', async () => {
await signIn()
const library = useLibraryStore()
let finish!: (response: Response) => void
fetchMock().mockImplementationOnce(() => new Promise<Response>(resolve => { finish = resolve }))
fetchMock().mockResolvedValueOnce(ok({ items: [summary({ title: '较新的标题' })] }))
const stale = library.loadBooks()
await library.loadBooks()
finish(ok({ items: [summary({ title: '过期的标题' })] }))
await stale
expect(library.books).toHaveLength(1)
expect(library.books[0]?.title).toBe('较新的标题')
})
// Regression R1: the append contract has no language field and the server rejects unknown
// fields, so a client that sent one could never append.
it('sends the language only when creating a book, never when appending', async () => {
await signIn()
const library = useLibraryStore()
fetchMock().mockResolvedValueOnce(created({ book, chapter: chapter({ id: 54 }) }))
await library.submit({ title: ' 新书 ', text: 'New book text.', target: { mode: 'new' } })
expect(bodyOf(1)).toEqual({ requestId: expect.any(String), title: '新书', text: 'New book text.', language: 'en' })
fetchMock().mockResolvedValueOnce(created({ chapter: chapter({ id: 55, ordinal: 2 }) }))
await library.submit({ title: '第二篇', text: 'Appended text.', target: { mode: 'append', bookId: 1 } })
expect(String(fetchMock().mock.calls[2]?.[0])).toBe('/api/v1/books/1/chapters')
expect(bodyOf(2)).toEqual({ requestId: expect.any(String), title: '第二篇', text: 'Appended text.' })
expect(bodyOf(2)).not.toHaveProperty('language')
})
// Regression R3: leaving a view must invalidate its in-flight request.
it('ignores a book response that arrives after the book view was closed', async () => {
vi.useFakeTimers()
await signIn()
const library = useLibraryStore()
let finish!: (response: Response) => void
fetchMock().mockImplementationOnce(() => new Promise<Response>(resolve => { finish = resolve }))
const pending = library.loadBook(1)
library.closeBook()
finish(ok({ book, chapters: [chapter()] }))
await pending
expect(library.book).toBeNull()
expect(library.chapters).toEqual([])
expect(library.bookLoading).toBe(false)
const settled = paths().length
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 3)
expect(paths().length).toBe(settled)
})
it('ignores a chapter response that arrives after the reader was closed', async () => {
vi.useFakeTimers()
await signIn()
const library = useLibraryStore()
let finish!: (response: Response) => void
fetchMock().mockImplementationOnce(() => new Promise<Response>(resolve => { finish = resolve }))
const pending = library.loadChapter(55)
library.closeChapter()
finish(ok({ book, chapter: chapter({ status: 'ready', originalText: 'Late text.' }), navigation }))
await pending
expect(library.chapter).toBeNull()
expect(library.readerText).toBe('')
expect(library.chapterLoading).toBe(false)
const settled = paths().length
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 3)
expect(paths().length).toBe(settled)
})
// Regression R4: an accepted retry must be visible and tracked even if the refresh fails.
it('keeps tracking a retried chapter when the first refresh fails', async () => {
vi.useFakeTimers()
await signIn()
const library = useLibraryStore()
fetchMock().mockResolvedValueOnce(ok({ book, chapter: chapter({ status: 'failed', errorReason: 'content_changed', errorMessage: '内容在处理前发生变化。', jobId: 7 }), navigation }))
await library.loadChapter(55)
expect(library.chapter?.status).toBe('failed')
fetchMock()
.mockResolvedValueOnce(ok({ job: { ...job, status: 'pending' }, chapter: chapter({ status: 'pending' }) }))
.mockRejectedValueOnce(new Error('network down'))
await library.retryChapter(55)
expect(paths()).toContain('/api/v1/jobs/7/retry')
expect(library.chapter?.status).toBe('pending')
expect(library.readerText).toBe('')
// The next poll still tracks the queued chapter and shows the final result.
fetchMock().mockResolvedValueOnce(ok({ book, chapter: chapter({ status: 'ready', originalText: 'Recovered text.' }), navigation }))
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS)
expect(library.chapter?.status).toBe('ready')
expect(library.readerText).toBe('Recovered text.')
})
it('keeps tracking a retried chapter from the book page when the first refresh fails', async () => {
vi.useFakeTimers()
await signIn()
const library = useLibraryStore()
fetchMock().mockResolvedValueOnce(ok({ book, chapters: [chapter({ status: 'failed', errorReason: 'content_changed', errorMessage: '内容在处理前发生变化。' })] }))
await library.loadBook(1)
expect(library.chapters[0]?.status).toBe('failed')
fetchMock()
.mockResolvedValueOnce(ok({ job: { ...job, status: 'pending' }, chapter: chapter({ status: 'pending' }) }))
.mockRejectedValueOnce(new Error('network down'))
await library.retryChapter(55)
expect(library.chapters[0]?.status).toBe('pending')
fetchMock().mockResolvedValueOnce(ok({ book, chapters: [chapter({ status: 'ready' })] }))
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS)
expect(library.chapters[0]?.status).toBe('ready')
})
})