liseur-desktop/tests/unit/library-service.test.ts
Chmouel Boudjnah 0a00ccb27b
Some checks are pending
CI / check (push) Waiting to run
CI / Lockfile change (push) Waiting to run
CI / Homebrew cask (push) Waiting to run
feat: Group multi-volume series into collapsible library stacks
Extracted series metadata from EPUB files and remote sync providers to
fold multi-volume series into interactive stacks on the main shelf.
Added a dedicated series view so readers could browse ordered volumes
without cluttering the main library screen.

Signed-off-by: Chmouel Boudjnah <chmouel@chmouel.com>
2026-09-01 08:36:54 +02:00

205 lines
6.6 KiB
TypeScript

import { describe, expect, it, beforeEach } from 'vitest'
import type { DatabaseSync } from 'node:sqlite'
import { openDatabase, migrate } from '../../src/worker/db/database'
import { MIGRATIONS } from '../../src/worker/db/migrations'
import { BookRepository } from '../../src/worker/library/book-repository'
import { LibraryService } from '../../src/worker/library/library-service'
import type { Book, LibraryQuery } from '../../src/shared/domain/types'
/**
* The Milestone 1 in-memory semantics, now verified against real SQL on an
* in-memory database.
*/
function book(partial: Partial<Book> & { id: string }): Book {
return {
title: `Title ${partial.id}`,
authors: ['Author'],
downloaded: false,
finished: false,
archived: false,
addedAt: 0,
...partial,
}
}
const BOOKS: Book[] = [
book({ id: 'a', title: 'The Glass Harbor', authors: ['Lea Frost'], addedAt: 100 }),
book({
id: 'b',
title: 'River of Stars',
authors: ['Otto Grey'],
addedAt: 200,
downloaded: true,
}),
book({
id: 'c',
title: 'Beneath the Tide',
authors: ['Lea Frost'],
addedAt: 300,
finished: true,
progress: { locator: { href: 'c.xhtml' }, progression: 1, updatedAt: 500 },
}),
book({ id: 'd', title: 'The Archive', authors: ['Sana Bell'], addedAt: 400, archived: true }),
book({
id: 'e',
title: 'Signal at Dawn',
authors: ['Amara Quinn'],
addedAt: 500,
lastOpenedAt: 900,
progress: { locator: { href: 'e.xhtml' }, progression: 0.4, updatedAt: 900 },
}),
]
const base: LibraryQuery = { filter: 'all', sort: 'added', direction: 'desc', search: '' }
let db: DatabaseSync
beforeEach(() => {
db = openDatabase(':memory:')
migrate(db, MIGRATIONS)
new BookRepository(db).insertBooks(BOOKS)
})
function query(overrides: Partial<LibraryQuery>) {
return new LibraryService(db).query({ ...base, ...overrides }, 1)
}
describe('LibraryService.query', () => {
it('filters: all excludes archived', () => {
const r = query({})
expect(r.books.map((b) => b.id)).toEqual(['e', 'c', 'b', 'a'])
})
it('filters: downloaded', () => {
expect(query({ filter: 'downloaded' }).books.map((b) => b.id)).toEqual(['b'])
})
it('filters: unread excludes finished and archived', () => {
expect(query({ filter: 'unread' }).books.map((b) => b.id)).toEqual(['e', 'b', 'a'])
})
it('filters: archived only', () => {
expect(query({ filter: 'archived' }).books.map((b) => b.id)).toEqual(['d'])
})
it('counts archived books whatever the filter asked for', () => {
// The library only offers the Archived chip once something is in there,
// so the count has to survive a query that excludes archived books.
expect(query({}).archivedCount).toBe(1)
expect(query({ filter: 'unread' }).archivedCount).toBe(1)
expect(query({ filter: 'archived' }).archivedCount).toBe(1)
expect(query({ search: 'nothing matches this' }).archivedCount).toBe(1)
})
it('searches title and author case-insensitively', () => {
expect(query({ search: 'glass' }).books.map((b) => b.id)).toEqual(['a'])
expect(query({ search: 'LEA' }).books.map((b) => b.id)).toEqual(['c', 'a'])
})
it('searches series names without matching JSON field names', () => {
new BookRepository(db).insertBooks([
book({ id: 'series', title: 'A Book', series: [{ name: 'The Expanse', position: 1 }] }),
])
expect(query({ search: 'expanse' }).books.map((b) => b.id)).toEqual(['series'])
expect(query({ search: 'position' }).books).toEqual([])
})
it('treats LIKE wildcards in search as literal characters', () => {
expect(query({ search: '%' }).books).toEqual([])
expect(query({ search: '_' }).books).toEqual([])
})
it('sorts by title ascending and descending', () => {
expect(query({ sort: 'title', direction: 'asc' }).books.map((b) => b.id)).toEqual([
'c',
'b',
'e',
'a',
])
expect(query({ sort: 'title', direction: 'desc' }).books.map((b) => b.id)).toEqual([
'a',
'e',
'b',
'c',
])
})
it('sorts by author using the first author', () => {
expect(query({ sort: 'author', direction: 'asc' }).books.map((b) => b.id)).toEqual([
'e', // Amara Quinn
'a', // Lea Frost — tie with 'c' broken by id
'c', // Lea Frost
'b', // Otto Grey
])
})
it('sorts by recent using lastOpenedAt', () => {
const r = query({ sort: 'recent', direction: 'desc' })
expect(r.books[0]?.id).toBe('e')
})
it('echoes requestId and totalCount', () => {
const r = new LibraryService(db).query(base, 42)
expect(r.requestId).toBe(42)
expect(r.totalCount).toBe(4)
})
it('round-trips optional fields', () => {
new BookRepository(db).insertBooks([
book({
id: 'f',
localPath: '/books/f.epub',
remoteId: 'remote-1',
coverId: 'cover-1',
lastOpenedAt: 1234,
}),
])
const f = query({ search: 'Title f' }).books[0]
expect(f).toMatchObject({
localPath: '/books/f.epub',
remoteId: 'remote-1',
coverId: 'cover-1',
lastOpenedAt: 1234,
})
})
})
describe('LibraryService.continueReading', () => {
it('returns the most recently updated in-progress book', () => {
expect(new LibraryService(db).continueReading()?.id).toBe('e')
})
it('returns null when nothing is in progress', () => {
const none = BOOKS.filter((b) => !b.progress || b.finished)
const empty = openDatabase(':memory:')
migrate(empty, MIGRATIONS)
new BookRepository(empty).insertBooks(none)
expect(new LibraryService(empty).continueReading()).toBeNull()
})
it('follows you to the book you just opened, before you turn a page', () => {
// Opening a book is the moment it becomes the book you are reading.
// Waiting for a saved position left the previous book on the banner,
// and a book opened for the first time has no position to save.
const repo = new BookRepository(db)
repo.touchOpened('b', 1_000)
expect(new LibraryService(db).continueReading()?.id).toBe('b')
})
it('picks up a book you were reading on another device', () => {
// Progress arriving from a server counts as reading, even though this
// machine never opened the book.
const repo = new BookRepository(db)
repo.touchOpened('b', 1_000)
repo.setProgress('a', { href: 'a.xhtml' }, 0.2, 2_000)
expect(new LibraryService(db).continueReading()?.id).toBe('a')
})
it('does not offer a book you have finished or archived', () => {
const repo = new BookRepository(db)
repo.touchOpened('d', 3_000) // archived
repo.touchOpened('c', 4_000) // finished
expect(new LibraryService(db).continueReading()?.id).toBe('e')
})
})