fix(stats): stop deleted KOReader sessions returning and surface reset holds

Deleting a KOReader session left its page events stored, so the next upload within the
session gap re-derived it. Record the deleted span in koreader_session_tombstones and skip
those events during derivation. Show the reset-hold notice on the Reading Log as well,
since Reset is reachable there and a held device otherwise reads as unexplained progress.

Refs #1355
This commit is contained in:
neon
2026-10-13 01:33:09 +00:00
parent 9c15a9a5cc
commit 613c7d2ef2
20 changed files with 24724 additions and 83 deletions
@@ -180,8 +180,9 @@ function handleFinishedCancel() {
const readerProgress = ref(0)
const progressLoaded = ref(false)
async function loadProgress() {
progressLoaded.value = false
/** `silent` keeps the current figure on screen while it is reread, so the ring does not blank. */
async function loadProgress(silent = false) {
if (!silent) progressLoaded.value = false
const bookId = props.book.id
const hasAudio = props.book.files.some((file) => file.format != null && isAudioFormat(file.format))
try {
@@ -210,7 +211,15 @@ async function loadProgress() {
}
}
watch(() => props.book.id, loadProgress, { immediate: true })
watch(
() => props.book.id,
() => loadProgress(),
{ immediate: true },
)
// The stored position can move without the book changing, such as a held KOReader position being
// accepted, so the tab asks for a reread rather than this card watching for it.
defineExpose({ reloadProgress: () => loadProgress(true) })
// The stored reading position only moves when a reader or a synced device writes it, so a book
// whose progress was only ever logged by hand has none. Sessions are the other observation of how
@@ -360,6 +369,10 @@ function handleAddSession() {
</div>
</div>
<div v-if="$slots.notice" class="mt-2.5 shrink-0">
<slot name="notice" />
</div>
<dl class="mt-3 shrink-0">
<div class="flex h-[26px] items-center gap-2">
<dt class="text-xs leading-4 text-muted-foreground">{{ t('book.detail.readingLog.vitals.started') }}</dt>
@@ -59,6 +59,7 @@ import MetadataScoreBreakdown from '@/features/metadata-score/components/Metadat
import { useMetadataScoreWeights } from '@/features/metadata-score/composables/useMetadataScoreWeights'
import { useSafeHtml } from '@/features/book/composables/useSafeHtml'
import { useKoreaderBookProgress } from '@/features/koreader/composables/useKoreaderBookProgress'
import KoreaderResetHoldNotice from '@/features/koreader/components/KoreaderResetHoldNotice.vue'
import { RATING_STARS, getRatingStarClass } from '@/features/book/lib/rating-stars'
import { formatCommunityRatingValue } from '@/features/book/lib/community-rating'
import BookCoverSurface from '@/features/book/components/BookCoverSurface.vue'
@@ -880,28 +881,9 @@ function formatKoboDeviceNames(snapshots: BookKoboState['snapshots']): string {
}
const resetHeldDevices = computed(() => (canViewKoreader.value ? (koreaderBookProgress.value?.heldByReset ?? []) : []))
const releasingDeviceIds = ref<string[]>([])
const failedReleaseDeviceIds = ref<string[]>([])
function isReleasingHold(deviceId: string): boolean {
return releasingDeviceIds.value.includes(deviceId)
}
function hasReleaseFailed(deviceId: string): boolean {
return failedReleaseDeviceIds.value.includes(deviceId)
}
async function handleReleaseResetHold(deviceId: string) {
if (isReleasingHold(deviceId)) return
releasingDeviceIds.value = [...releasingDeviceIds.value, deviceId]
failedReleaseDeviceIds.value = failedReleaseDeviceIds.value.filter((id) => id !== deviceId)
try {
const released = await releaseKoreaderResetHold(props.book.id, deviceId)
// A button that re-enables with the hold still showing reads as nothing having happened.
if (!released) failedReleaseDeviceIds.value = [...failedReleaseDeviceIds.value, deviceId]
} finally {
releasingDeviceIds.value = releasingDeviceIds.value.filter((id) => id !== deviceId)
}
function releaseResetHold(deviceId: string): Promise<boolean> {
return releaseKoreaderResetHold(props.book.id, deviceId)
}
const koboAnomaly = computed(() => {
@@ -966,13 +948,6 @@ function formatAddedDateTime(iso: string): string {
})
}
function formatPercent(value: number): string {
const clamped = Math.max(0, Math.min(100, value))
if (clamped > 0 && clamped < 1) return '<1%'
if (clamped > 99 && clamped < 100) return '>99%'
return `${Math.round(clamped)}%`
}
function formatDate(iso: string): string {
return formatLocaleDate(new Date(iso), { year: 'numeric', month: 'short', day: 'numeric', timeZone: userTimeZone.value })
}
@@ -1491,24 +1466,7 @@ watch(
</div>
</div>
<div v-for="held in resetHeldDevices" :key="held.deviceId" class="mt-2 flex items-start gap-1.5">
<TriangleAlert class="size-3 text-amber-500 shrink-0 mt-0.5" aria-hidden="true" />
<div class="min-w-0">
<p class="text-[11px] text-amber-500">
{{ t('book.detail.details.resetHoldNotice', { device: held.device, percent: formatPercent(held.percentage) }) }}
</p>
<button
class="mt-0.5 text-[11px] text-muted-foreground underline underline-offset-2 hover:text-foreground focus-visible:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
:disabled="isReleasingHold(held.deviceId)"
@click="handleReleaseResetHold(held.deviceId)"
>
{{ isReleasingHold(held.deviceId) ? t('book.detail.details.resetHoldReleasing') : t('book.detail.details.resetHoldRelease') }}
</button>
<p v-if="hasReleaseFailed(held.deviceId)" role="alert" class="text-[11px] text-destructive">
{{ t('book.detail.details.resetHoldReleaseFailed') }}
</p>
</div>
</div>
<KoreaderResetHoldNotice v-if="resetHeldDevices.length > 0" class="mt-2" :held="resetHeldDevices" :release="releaseResetHold" />
<Tooltip v-if="koboAnomaly">
<TooltipTrigger as-child>
<div class="mt-2 flex items-center gap-1.5 cursor-help" tabindex="0">
@@ -9,6 +9,8 @@ import { useDeferredLoading } from '@/composables/useDeferredLoading'
import { useBookReadingLog, type AddReadingSessionPayload } from '@/features/book/composables/useBookReadingLog'
import { useReadingAttempts, type ReadingAttemptDraft } from '@/features/book/composables/useReadingAttempts'
import { useResetReadingState } from '@/features/book/composables/useResetReadingState'
import { useKoreaderBookProgress } from '@/features/koreader/composables/useKoreaderBookProgress'
import KoreaderResetHoldNotice from '@/features/koreader/components/KoreaderResetHoldNotice.vue'
import ResetReadingStateDialog from '@/features/book/components/ResetReadingStateDialog.vue'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import ReadingLogVitals from '../reading-log/ReadingLogVitals.vue'
@@ -27,6 +29,7 @@ const props = defineProps<{ book: BookDetail }>()
const emit = defineEmits<{
saved: [book: BookDetail]
refetch: []
}>()
const { t } = useI18n()
@@ -73,6 +76,7 @@ onActivated(() => {
}
void reload({ silent: true })
void reloadAttempts({ silent: true })
loadResetHolds()
})
const { hasPermission } = usePermissions()
@@ -86,6 +90,42 @@ const {
resetReadingState,
} = useResetReadingState(bookIdRef)
// Reset is reachable from this tab, and a KOReader device that declines it keeps reading behind a
// hold: its sessions keep arriving while the book stays at the start. Without the notice here the
// tab shows that progress beside an unread status and nothing to explain or resolve it.
const canViewKoreader = computed(() => hasPermission(Permission.KoreaderSync))
const vitalsRef = ref<InstanceType<typeof ReadingLogVitals> | null>(null)
const {
bookProgress: koreaderBookProgress,
fetchBookProgress: fetchKoreaderBookProgress,
releaseResetHold: releaseKoreaderResetHold,
} = useKoreaderBookProgress()
const resetHeldDevices = computed(() => {
const progress = koreaderBookProgress.value
if (!canViewKoreader.value || progress?.bookId !== props.book.id) return []
return progress.heldByReset ?? []
})
function loadResetHolds() {
if (!canViewKoreader.value) return
void fetchKoreaderBookProgress(props.book.id)
}
watch(bookIdRef, loadResetHolds, { immediate: true })
// Accepting the device position moves the book's progress and status, and can open an attempt.
async function releaseResetHold(deviceId: string): Promise<boolean> {
const released = await releaseKoreaderResetHold(props.book.id, deviceId)
if (released) {
void vitalsRef.value?.reloadProgress()
void reload({ silent: true })
void reloadAttempts({ silent: true })
emit('refetch')
}
return released
}
type QuickFilter = 'all' | 'last30' | 'last90' | 'thisYear'
const activeQuick = ref<QuickFilter>('all')
const selectedFormat = ref<string | undefined>(undefined)
@@ -172,6 +212,7 @@ async function handleResetReadingState() {
if (!result) return
await reload()
await reloadAttempts()
loadResetHolds()
emit('saved', { ...props.book, readStatus: result.readStatus })
}
@@ -253,6 +294,7 @@ const quickFilters = computed<{ label: string; value: QuickFilter }[]>(() => [
"
>
<ReadingLogVitals
ref="vitalsRef"
class="xl:col-start-1 xl:row-start-1"
:class="{ 'xl:row-span-2': !resolved }"
:book="book"
@@ -261,7 +303,11 @@ const quickFilters = computed<{ label: string; value: QuickFilter }[]>(() => [
:hide-add-session="blank || !resolved"
@saved="handleVitalsSaved"
@add-session="handleOpenAddSession"
/>
>
<template v-if="resetHeldDevices.length > 0" #notice>
<KoreaderResetHoldNotice :held="resetHeldDevices" :release="releaseResetHold" />
</template>
</ReadingLogVitals>
<div
v-if="!resolved"
@@ -672,6 +672,50 @@ describe('DetailsTab cover surface', () => {
])
})
it('hands held KOReader devices to the reset hold notice and releases them for this book', async () => {
const heldDevice = { device: 'Kindle', deviceId: 'kindle-1', percentage: 26.67, updatedAt: '2026-10-12T00:00:00.000Z' }
let held = [heldDevice]
const baseRoute = mocks.api.getMockImplementation()!
mocks.api.mockImplementation(async (input, init) => {
const url = String(input)
if (url.endsWith('/reset-hold/release')) {
held = []
return { ok: true, status: 204, json: async () => ({}) } as Response
}
if (url.includes('/koreader/books/')) return response({ bookId: 12, heldByReset: held })
return baseRoute(input, init)
})
const wrapper = mountDetails(makeBook())
await flushPromises()
const notice = wrapper.findComponent({ name: 'KoreaderResetHoldNotice' })
expect(notice.props('held')).toEqual([heldDevice])
const released = await (notice.props('release') as (deviceId: string) => Promise<boolean>)('kindle-1')
await flushPromises()
expect(released).toBe(true)
expect(mocks.api).toHaveBeenCalledWith(
'/api/v1/koreader/books/12/reset-hold/release',
expect.objectContaining({ method: 'POST', body: JSON.stringify({ deviceId: 'kindle-1' }) }),
)
expect(wrapper.findComponent({ name: 'KoreaderResetHoldNotice' }).exists()).toBe(false)
})
it('shows no reset hold notice without the KOReader permission', async () => {
mocks.hasPermission.mockImplementation((permission) => permission !== 'koreader_sync')
const baseRoute = mocks.api.getMockImplementation()!
mocks.api.mockImplementation(async (input, init) => {
if (String(input).includes('/koreader/books/'))
return response({ bookId: 12, heldByReset: [{ device: 'Kindle', deviceId: 'k', percentage: 40, updatedAt: '' }] })
return baseRoute(input, init)
})
const wrapper = mountDetails(makeBook())
await flushPromises()
expect(wrapper.findComponent({ name: 'KoreaderResetHoldNotice' }).exists()).toBe(false)
})
it('hides reset reading state when the user cannot edit metadata', async () => {
mocks.hasPermission.mockReturnValue(false)
const wrapper = mountDetails(makeBook())
@@ -2,7 +2,8 @@ import { mount, flushPromises } from '@vue/test-utils'
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { createPinia } from 'pinia'
import { RouterLinkStub } from '@vue/test-utils'
import type { BookDetail, BookReadingSession, BookReadingSessionStats, ReadingAttempt } from '@bookorbit/types'
import { defineComponent, h, KeepAlive, nextTick, ref } from 'vue'
import type { BookDetail, BookReadingSession, BookReadingSessionStats, KoreaderBookSyncInfo, ReadingAttempt } from '@bookorbit/types'
const mocks = vi.hoisted(() => ({
api: vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>(),
@@ -161,12 +162,31 @@ function jsonResponse(body: unknown) {
return { ok: true, status: 200, json: async () => body } as Response
}
/** Routes each request the tab makes on mount: sessions, attempts and the two progress reads. */
function routeApi(options: { sessions?: BookReadingSession[]; attempts?: ReadingAttempt[] } = {}) {
function makeKoreaderProgress(overrides: Partial<KoreaderBookSyncInfo> = {}): KoreaderBookSyncInfo {
return {
bookId: 10,
bookFileId: 17,
canonicalPercentage: 0,
canonicalChapterIndex: null,
canonicalChapterTitle: null,
canonicalSource: 'web_reader',
canonicalUpdatedAt: '1970-01-01T00:00:00.000Z',
devices: [],
fileModifiedSinceLastSync: false,
heldByReset: [],
...overrides,
}
}
const HELD_KINDLE = { device: 'Kindle', deviceId: 'kindle-1', percentage: 26.67, updatedAt: '2026-10-12T00:00:00.000Z' }
/** Routes each request the tab makes on mount: sessions, attempts, the progress reads and KOReader holds. */
function routeApi(options: { sessions?: BookReadingSession[]; attempts?: ReadingAttempt[]; koreader?: () => KoreaderBookSyncInfo } = {}) {
const sessions = options.sessions ?? []
const attempts = options.attempts ?? []
return (input: RequestInfo | URL) => {
const url = String(input)
if (url.includes('/koreader/books/')) return Promise.resolve(jsonResponse(options.koreader?.() ?? makeKoreaderProgress()))
if (url.includes('/reading-attempts')) return Promise.resolve(jsonResponse({ items: attempts, page: 1, pageSize: 50, total: attempts.length }))
if (url.includes('/sessions')) {
return Promise.resolve(jsonResponse({ items: sessions, total: sessions.length, page: 1, pageSize: 25, stats: makeStats(sessions) }))
@@ -342,6 +362,161 @@ describe('ReadingLogTab', () => {
expect(wrapper.emitted('saved')?.[0]).toEqual([expect.objectContaining({ readStatus })])
})
describe('KOReader reset hold', () => {
function holdRows(wrapper: ReturnType<typeof mountTab>) {
return wrapper.findAll('[data-testid="koreader-reset-hold"]')
}
it('explains a held device inside the vitals card', async () => {
mocks.api.mockImplementation(
routeApi({
sessions: [makeSession({ source: 'koreader', endProgress: 26.67 })],
koreader: () => makeKoreaderProgress({ heldByReset: [HELD_KINDLE] }),
}),
)
const wrapper = mountTab()
await flushPromises()
expect(mocks.api).toHaveBeenCalledWith('/api/v1/koreader/books/10/progress')
const vitals = wrapper.get('section[aria-label]')
const rows = vitals.findAll('[data-testid="koreader-reset-hold"]')
expect(rows).toHaveLength(1)
expect(rows[0]!.text()).toContain('Kindle still has the old position (27%)')
expect(rows[0]!.text()).toContain("Use this device's position")
})
it('shows the notice on an otherwise blank log, where a reset leaves it', async () => {
mocks.api.mockImplementation(routeApi({ koreader: () => makeKoreaderProgress({ heldByReset: [HELD_KINDLE] }) }))
const wrapper = mountTab()
await flushPromises()
expect(wrapper.text()).toContain('Nothing logged for this book yet')
expect(holdRows(wrapper)).toHaveLength(1)
})
it('shows nothing when no device is held', async () => {
mocks.api.mockImplementation(routeApi({ sessions: [makeSession()] }))
const wrapper = mountTab()
await flushPromises()
expect(holdRows(wrapper)).toHaveLength(0)
expect(wrapper.text()).not.toContain('still has the old position')
})
it('neither asks for nor shows holds without the KOReader permission', async () => {
mocks.hasPermission.mockImplementation((permission) => permission !== 'koreader_sync')
mocks.api.mockImplementation(routeApi({ sessions: [makeSession()], koreader: () => makeKoreaderProgress({ heldByReset: [HELD_KINDLE] }) }))
const wrapper = mountTab()
await flushPromises()
expect(mocks.api.mock.calls.some((call) => String(call[0]).includes('/koreader/'))).toBe(false)
expect(holdRows(wrapper)).toHaveLength(0)
})
it('ignores a hold answered for a different book', async () => {
mocks.api.mockImplementation(
routeApi({ sessions: [makeSession()], koreader: () => makeKoreaderProgress({ bookId: 99, heldByReset: [HELD_KINDLE] }) }),
)
const wrapper = mountTab()
await flushPromises()
expect(holdRows(wrapper)).toHaveLength(0)
})
it('checks for a hold again once a reset from this tab completes', async () => {
let held: (typeof HELD_KINDLE)[] = []
mocks.api.mockImplementation((input) => {
const url = String(input)
if (url.includes('reset-reading-state')) {
held = [HELD_KINDLE]
return Promise.resolve(
jsonResponse({
readStatus: { status: 'unread', source: 'manual', startedAt: null, finishedAt: null, updatedAt: '2026-10-12T00:00:00.000Z' },
}),
)
}
return routeApi({ sessions: [makeSession()], koreader: () => makeKoreaderProgress({ heldByReset: held }) })(input)
})
const wrapper = mountTab()
await flushPromises()
expect(holdRows(wrapper)).toHaveLength(0)
wrapper.findComponent(ResetReadingStateDialog).vm.$emit('confirm')
await flushPromises()
expect(holdRows(wrapper)).toHaveLength(1)
})
it('accepts the device position and refreshes everything it moves', async () => {
let held = [HELD_KINDLE]
mocks.api.mockImplementation((input) => {
const url = String(input)
if (url.endsWith('/reset-hold/release')) {
held = []
return Promise.resolve({ ok: true, status: 204, json: async () => ({}) } as Response)
}
if (url === '/api/v1/books/10/progress') return Promise.resolve(jsonResponse(held.length ? [] : [{ fileId: 17, percentage: 26.67 }]))
return routeApi({ sessions: [makeSession({ source: 'koreader' })], koreader: () => makeKoreaderProgress({ heldByReset: held }) })(input)
})
const wrapper = mountTab()
await flushPromises()
mocks.api.mockClear()
await holdRows(wrapper)[0]!.get('button').trigger('click')
await flushPromises()
const releaseCall = mocks.api.mock.calls.find((call) => String(call[0]).endsWith('/reset-hold/release'))
expect(releaseCall?.[0]).toBe('/api/v1/koreader/books/10/reset-hold/release')
expect(releaseCall?.[1]).toMatchObject({ method: 'POST', body: JSON.stringify({ deviceId: 'kindle-1' }) })
const urls = mocks.api.mock.calls.map((call) => String(call[0]))
expect(urls).toContain('/api/v1/books/10/progress')
expect(urls.some((url) => url.startsWith('/api/v1/books/10/sessions?'))).toBe(true)
expect(urls.some((url) => url.includes('/api/v1/books/10/reading-attempts'))).toBe(true)
expect(wrapper.emitted('refetch')).toHaveLength(1)
expect(holdRows(wrapper)).toHaveLength(0)
expect(wrapper.get('section[aria-label]').text()).toContain('27%')
})
it('keeps the notice and reports the failure when the release is refused', async () => {
mocks.api.mockImplementation((input) => {
if (String(input).endsWith('/reset-hold/release')) return Promise.resolve({ ok: false, status: 404, json: async () => ({}) } as Response)
return routeApi({ sessions: [makeSession()], koreader: () => makeKoreaderProgress({ heldByReset: [HELD_KINDLE] }) })(input)
})
const wrapper = mountTab()
await flushPromises()
await holdRows(wrapper)[0]!.get('button').trigger('click')
await flushPromises()
expect(holdRows(wrapper)).toHaveLength(1)
expect(wrapper.get('[role="alert"]').text()).toBe('Could not use this position. Try again.')
expect(wrapper.emitted('refetch')).toBeUndefined()
})
it('rechecks the hold when the kept-alive tab is shown again', async () => {
let held: (typeof HELD_KINDLE)[] = []
mocks.api.mockImplementation(routeApi({ sessions: [makeSession()], koreader: () => makeKoreaderProgress({ heldByReset: held }) }))
const visible = ref(true)
const Host = defineComponent({
setup() {
return () => h(KeepAlive, null, [visible.value ? h(ReadingLogTab, { book: makeBook() }) : h('div')])
},
})
const wrapper = mount(Host, { global: { plugins: [createPinia()], stubs: { RouterLink: RouterLinkStub } } })
await flushPromises()
expect(holdRows(wrapper as unknown as ReturnType<typeof mountTab>)).toHaveLength(0)
held = [HELD_KINDLE]
visible.value = false
await nextTick()
visible.value = true
await flushPromises()
expect(holdRows(wrapper as unknown as ReturnType<typeof mountTab>)).toHaveLength(1)
})
})
it('hides the reset action without the metadata-edit permission', async () => {
mocks.hasPermission.mockReturnValue(false)
mocks.api.mockImplementation(routeApi({ sessions: [makeSession()] }))
@@ -0,0 +1,71 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { TriangleAlert } from '@lucide/vue'
import type { KoreaderResetHeldDevice } from '@bookorbit/types'
import { formatPercent } from '@/i18n/formatters'
// Release is a callback rather than an event because a successful release refetches the holds,
// which unmounts this notice when it was the last one before an emitted event could be heard.
const props = defineProps<{
held: KoreaderResetHeldDevice[]
release: (deviceId: string) => Promise<boolean>
}>()
const { t } = useI18n()
const releasingDeviceIds = ref<string[]>([])
const failedReleaseDeviceIds = ref<string[]>([])
function isReleasing(deviceId: string): boolean {
return releasingDeviceIds.value.includes(deviceId)
}
function hasReleaseFailed(deviceId: string): boolean {
return failedReleaseDeviceIds.value.includes(deviceId)
}
// A held device is never at the start or the end, so rounding must not report it as 0% or 100%.
function heldPercent(percentage: number): string {
const clamped = Math.max(0, Math.min(100, percentage))
const shown = clamped > 0 && clamped < 1 ? 1 : clamped > 99 && clamped < 100 ? 99 : Math.round(clamped)
return formatPercent(shown / 100)
}
async function handleRelease(deviceId: string) {
if (isReleasing(deviceId)) return
releasingDeviceIds.value = [...releasingDeviceIds.value, deviceId]
failedReleaseDeviceIds.value = failedReleaseDeviceIds.value.filter((id) => id !== deviceId)
try {
const released = await props.release(deviceId).catch(() => false)
// A button that re-enables with the hold still showing reads as nothing having happened.
if (!released) failedReleaseDeviceIds.value = [...failedReleaseDeviceIds.value, deviceId]
} finally {
releasingDeviceIds.value = releasingDeviceIds.value.filter((id) => id !== deviceId)
}
}
</script>
<template>
<div class="flex flex-col gap-2">
<div v-for="device in held" :key="device.deviceId" class="flex items-start gap-1.5" data-testid="koreader-reset-hold">
<TriangleAlert class="mt-0.5 size-3 shrink-0 text-warning" aria-hidden="true" />
<div class="min-w-0">
<p class="text-[11px] text-warning">
{{ t('book.detail.details.resetHoldNotice', { device: device.device, percent: heldPercent(device.percentage) }) }}
</p>
<button
type="button"
class="mt-0.5 text-[11px] text-muted-foreground underline underline-offset-2 hover:text-foreground focus-visible:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
:disabled="isReleasing(device.deviceId)"
@click="handleRelease(device.deviceId)"
>
{{ isReleasing(device.deviceId) ? t('book.detail.details.resetHoldReleasing') : t('book.detail.details.resetHoldRelease') }}
</button>
<p v-if="hasReleaseFailed(device.deviceId)" role="alert" class="text-[11px] text-destructive">
{{ t('book.detail.details.resetHoldReleaseFailed') }}
</p>
</div>
</div>
</div>
</template>
@@ -0,0 +1,133 @@
import { flushPromises, mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import type { KoreaderResetHeldDevice } from '@bookorbit/types'
import KoreaderResetHoldNotice from '../KoreaderResetHoldNotice.vue'
type Release = (deviceId: string) => Promise<boolean>
function held(overrides: Partial<KoreaderResetHeldDevice> = {}): KoreaderResetHeldDevice {
return { device: 'Kindle', deviceId: 'kindle-1', percentage: 26.67, updatedAt: '2026-10-12T00:00:00.000Z', ...overrides }
}
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
function mountNotice(devices: KoreaderResetHeldDevice[], release: Release) {
return mount(KoreaderResetHoldNotice, { props: { held: devices, release } })
}
function rows(wrapper: ReturnType<typeof mountNotice>) {
return wrapper.findAll('[data-testid="koreader-reset-hold"]')
}
describe('KoreaderResetHoldNotice', () => {
it('names every held device with its position', () => {
const wrapper = mountNotice([held(), held({ device: 'Kobo Libra', deviceId: 'kobo-1', percentage: 50 })], vi.fn<Release>())
const text = rows(wrapper).map((row) => row.text())
expect(text).toHaveLength(2)
expect(text[0]).toContain('Kindle still has the old position (27%)')
expect(text[1]).toContain('Kobo Libra still has the old position (50%)')
})
it('renders nothing for an empty list', () => {
const wrapper = mountNotice([], vi.fn<Release>())
expect(rows(wrapper)).toHaveLength(0)
expect(wrapper.find('button').exists()).toBe(false)
})
it.each([
[0.4, '1%'],
[99.6, '99%'],
[99.4, '99%'],
[0, '0%'],
[100, '100%'],
[140, '100%'],
[-3, '0%'],
])('never rounds a held position of %s to the start or the end (%s)', (percentage, expected) => {
const wrapper = mountNotice([held({ percentage })], vi.fn<Release>())
expect(wrapper.text()).toContain(`old position (${expected})`)
})
it('releases the device it belongs to and reports success', async () => {
const release = vi.fn<Release>().mockResolvedValue(true)
const wrapper = mountNotice([held(), held({ device: 'Kobo', deviceId: 'kobo-1' })], release)
await rows(wrapper)[1]!.get('button').trigger('click')
await flushPromises()
expect(release).toHaveBeenCalledExactlyOnceWith('kobo-1')
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
})
it('disables only the pending button and ignores repeat clicks until the release settles', async () => {
const pending = deferred<boolean>()
const release = vi.fn<Release>().mockReturnValue(pending.promise)
const wrapper = mountNotice([held(), held({ device: 'Kobo', deviceId: 'kobo-1' })], release)
const [first, second] = rows(wrapper).map((row) => row.get('button'))
await first!.trigger('click')
await first!.trigger('click')
expect(release).toHaveBeenCalledTimes(1)
expect(first!.attributes('disabled')).toBeDefined()
expect(first!.text()).toBe('Releasing...')
expect(second!.attributes('disabled')).toBeUndefined()
expect(second!.text()).toBe("Use this device's position")
pending.resolve(true)
await flushPromises()
expect(first!.attributes('disabled')).toBeUndefined()
expect(first!.text()).toBe("Use this device's position")
})
it('keeps the hold and says so when the server refuses the release', async () => {
const release = vi.fn<Release>().mockResolvedValue(false)
const wrapper = mountNotice([held()], release)
await wrapper.get('button').trigger('click')
await flushPromises()
expect(wrapper.get('[role="alert"]').text()).toBe('Could not use this position. Try again.')
expect(wrapper.get('button').attributes('disabled')).toBeUndefined()
})
it('treats a failed request like a refusal instead of leaving the button stuck', async () => {
const release = vi.fn<Release>().mockRejectedValue(new Error('offline'))
const wrapper = mountNotice([held()], release)
await wrapper.get('button').trigger('click')
await flushPromises()
expect(wrapper.find('[role="alert"]').exists()).toBe(true)
expect(wrapper.get('button').attributes('disabled')).toBeUndefined()
})
it('clears the failure when the release is retried', async () => {
const release = vi.fn<Release>().mockResolvedValueOnce(false).mockResolvedValueOnce(true)
const wrapper = mountNotice([held()], release)
await wrapper.get('button').trigger('click')
await flushPromises()
expect(wrapper.find('[role="alert"]').exists()).toBe(true)
await wrapper.get('button').trigger('click')
await flushPromises()
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
expect(release).toHaveBeenCalledTimes(2)
})
it('marks the warning icon decorative', () => {
const wrapper = mountNotice([held()], vi.fn<Release>())
expect(wrapper.get('svg').attributes('aria-hidden')).toBe('true')
})
})
+5 -1
View File
@@ -127,6 +127,10 @@ function onLocksChanged(lockedFields: BookMetadataLockField[]) {
if (detail.value) detail.value.lockedFields = lockedFields
}
function handleBookRefetch() {
void fetch(bookId.value)
}
// A cover write can change either slot, the summary and the face version, so take the server's view.
function onCoverChanged() {
void fetch(bookId.value)
@@ -152,7 +156,7 @@ function onCoverChanged() {
@cover-changed="onCoverChanged"
/>
<FilesTab v-else-if="tab === 'files'" :book="detail" @refetch="fetch(detail.id)" />
<ReadingLogTab v-else-if="tab === 'reading-log'" :book="detail" @saved="onMetadataSaved" />
<ReadingLogTab v-else-if="tab === 'reading-log'" :book="detail" @saved="onMetadataSaved" @refetch="handleBookRefetch" />
<HighlightsTab v-else-if="tab === 'highlights'" :book="detail" />
</KeepAlive>
</div>