From 4fb176faf6e656083c9c26ebd59234f6d67ca7cb Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 17 May 2026 16:29:57 -0400 Subject: [PATCH] feat(reader): seriesReader store + pure scroll/progress/clamp helpers Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/stores/seriesReader.js | 56 ++++++++++++++++++++ frontend/test/seriesReader.spec.js | 81 +++++++++++++++++++++++++++++ 2 files changed, 137 insertions(+) create mode 100644 frontend/src/stores/seriesReader.js create mode 100644 frontend/test/seriesReader.spec.js diff --git a/frontend/src/stores/seriesReader.js b/frontend/src/stores/seriesReader.js new file mode 100644 index 0000000..f79b8d6 --- /dev/null +++ b/frontend/src/stores/seriesReader.js @@ -0,0 +1,56 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { useApi } from '../composables/useApi.js' + +// IR reader.js "viewport-center-in-third" rule, pure for unit tests. +// metrics: [{page_number, top, height}] in page order. +export function currentPageFromScroll(metrics, scrollTop, viewportH) { + if (!metrics.length) return 1 + const target = scrollTop + viewportH / 3 + let cur = metrics[0].page_number + for (const m of metrics) { + if (target >= m.top) cur = m.page_number + } + return cur +} + +export function progressPct(scrollTop, scrollHeight, clientHeight) { + const denom = scrollHeight - clientHeight + if (denom <= 0) return 0 + const pct = (scrollTop / denom) * 100 + return Math.min(100, Math.max(0, pct)) +} + +export function clampPage(n, total) { + if (!total || total < 1) return 1 + if (!Number.isFinite(n) || n < 1) return 1 + if (n > total) return total + return Math.floor(n) +} + +export const useSeriesReaderStore = defineStore('seriesReader', () => { + const api = useApi() + + const series = ref(null) + const pages = ref([]) + const loading = ref(false) + const error = ref(null) + + async function load(tagId) { + loading.value = true + error.value = null + try { + const body = await api.get(`/api/series/${tagId}/pages`) + series.value = body.series + pages.value = body.pages + } catch (e) { + error.value = e.message + series.value = null + pages.value = [] + } finally { + loading.value = false + } + } + + return { series, pages, loading, error, load } +}) diff --git a/frontend/test/seriesReader.spec.js b/frontend/test/seriesReader.spec.js new file mode 100644 index 0000000..de4d61e --- /dev/null +++ b/frontend/test/seriesReader.spec.js @@ -0,0 +1,81 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' +import { + useSeriesReaderStore, + currentPageFromScroll, + progressPct, + clampPage +} from '../src/stores/seriesReader.js' + +function stubFetch(handler) { + globalThis.fetch = vi.fn(async (url, init) => { + const { status, body } = handler(url, init) + return { + ok: status >= 200 && status < 300, + status, statusText: String(status), + text: async () => (body == null ? '' : JSON.stringify(body)) + } + }) +} + +const M = [ + { page_number: 1, top: 0, height: 100 }, + { page_number: 2, top: 100, height: 100 }, + { page_number: 3, top: 200, height: 100 } +] + +describe('seriesReader pure helpers', () => { + it('currentPageFromScroll: top of doc → page 1', () => { + expect(currentPageFromScroll(M, 0, 90)).toBe(1) // target 30 + }) + it('currentPageFromScroll: boundary crosses into page 2', () => { + expect(currentPageFromScroll(M, 80, 90)).toBe(2) // target 110 + }) + it('currentPageFromScroll: scrolled past last → last page', () => { + expect(currentPageFromScroll(M, 5000, 300)).toBe(3) + }) + it('currentPageFromScroll: empty metrics → 1', () => { + expect(currentPageFromScroll([], 0, 90)).toBe(1) + }) + it('progressPct: 0 at top, ~50 mid, clamped 100, zero-guard', () => { + expect(progressPct(0, 1000, 500)).toBe(0) + expect(progressPct(250, 1000, 500)).toBe(50) + expect(progressPct(9999, 1000, 500)).toBe(100) + expect(progressPct(0, 100, 100)).toBe(0) // denom 0 → 0 + }) + it('clampPage: below→1, above→total, valid passthrough, NaN→1', () => { + expect(clampPage(0, 5)).toBe(1) + expect(clampPage(99, 5)).toBe(5) + expect(clampPage(3, 5)).toBe(3) + expect(clampPage(NaN, 5)).toBe(1) + expect(clampPage(2, 0)).toBe(1) + }) +}) + +describe('seriesReader store', () => { + beforeEach(() => setActivePinia(createPinia())) + afterEach(() => vi.restoreAllMocks()) + + it('load() stores series + pages', async () => { + const s = useSeriesReaderStore() + stubFetch(() => ({ + status: 200, + body: { + series: { id: 7, name: 'V' }, + pages: [{ image_id: 1, page_number: 1, thumbnail_url: 't', image_url: 'i' }] + } + })) + await s.load(7) + expect(s.series).toEqual({ id: 7, name: 'V' }) + expect(s.pages[0].image_url).toBe('i') + expect(s.error).toBe(null) + }) + + it('load() on 404 sets error, no throw', async () => { + const s = useSeriesReaderStore() + stubFetch(() => ({ status: 404, body: { error: 'not a series' } })) + await s.load(9) + expect(s.error).toBe('not a series') + expect(s.pages).toEqual([]) + }) +})