Add a vertical-scroll continuous mode to the reader alongside the existing single-page mode. A segmented toggle in the reader top bar switches between them; in continuous mode a gap selector (None/Small/Medium/Large → 0/12/32/64px) controls the spacing between stacked pages. Settings page mirrors the same controls. Backend: new user_preferences table (one row per user, lazily inserted, ON DELETE CASCADE) and GET/PATCH /api/v1/auth/me/preferences gated by the existing CurrentUser extractor. Allowed values are enforced both by API validation and table-level CHECK constraints. Eight integration tests cover defaults, persistence, partial updates, validation errors, auth, per-user isolation, and cascade. Frontend: a new preferences store mirrors the theme-store pattern with a localStorage shadow so anonymous browsers get a consistent experience and logged-in users don't flash defaults while the server response is in flight. Server values that the frontend doesn't recognize (forward-compat) are ignored rather than poisoning the UI; non-401 PATCH errors revert the optimistic local update; logout clears the shadow so user A's settings don't follow user B on a shared browser. In continuous mode native scrolling handles Space/PageDown/arrows; Home/End remain wired and call scrollIntoView() so jumping to chapter bounds stays one keystroke. Single-page mode (chevrons, arrow-key pagination, next-page preload) is unchanged. Versions bumped 0.13.0 → 0.14.0 in lockstep. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
152 lines
5.9 KiB
TypeScript
152 lines
5.9 KiB
TypeScript
import {
|
|
describe,
|
|
it,
|
|
expect,
|
|
vi,
|
|
beforeEach,
|
|
afterEach,
|
|
type MockInstance
|
|
} from 'vitest';
|
|
import { preferences } from './preferences.svelte';
|
|
|
|
function ok(body: unknown, status = 200): Response {
|
|
return new Response(JSON.stringify(body), {
|
|
status,
|
|
headers: { 'content-type': 'application/json' }
|
|
});
|
|
}
|
|
|
|
function envelope(status: number, code: string, message: string): Response {
|
|
return new Response(JSON.stringify({ error: { code, message } }), {
|
|
status,
|
|
headers: { 'content-type': 'application/json' }
|
|
});
|
|
}
|
|
|
|
const flush = () => new Promise((r) => setTimeout(r, 0));
|
|
|
|
describe('preferences store', () => {
|
|
let fetchSpy: MockInstance<typeof globalThis.fetch>;
|
|
|
|
beforeEach(async () => {
|
|
localStorage.clear();
|
|
// Default fetch implementation returns 401 so the reset calls below
|
|
// don't escape as real network requests (jsdom can't resolve
|
|
// /api/...). Each test overrides with mockResolvedValueOnce.
|
|
fetchSpy = vi
|
|
.spyOn(globalThis, 'fetch')
|
|
.mockResolvedValue(envelope(401, 'unauthenticated', 'reset'));
|
|
// Reset the singleton's state for the next test.
|
|
preferences.setMode('single');
|
|
preferences.setGap('none');
|
|
await flush();
|
|
fetchSpy.mockReset();
|
|
});
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
localStorage.clear();
|
|
});
|
|
|
|
it('init hydrates from localStorage immediately and pulls server values when authenticated', async () => {
|
|
localStorage.setItem('mangalord-reader-mode', 'continuous');
|
|
localStorage.setItem('mangalord-reader-gap', 'small');
|
|
|
|
fetchSpy.mockResolvedValueOnce(
|
|
ok({
|
|
reader_mode: 'single',
|
|
reader_page_gap: 'large',
|
|
updated_at: '2026-05-17T12:00:00Z'
|
|
})
|
|
);
|
|
|
|
const p = preferences.init();
|
|
// Synchronous hydration from localStorage happens before the await.
|
|
expect(preferences.readerMode).toBe('continuous');
|
|
expect(preferences.readerPageGap).toBe('small');
|
|
|
|
await p;
|
|
// Server response overrides the localStorage values.
|
|
expect(preferences.readerMode).toBe('single');
|
|
expect(preferences.readerPageGap).toBe('large');
|
|
expect(localStorage.getItem('mangalord-reader-mode')).toBe('single');
|
|
expect(localStorage.getItem('mangalord-reader-gap')).toBe('large');
|
|
});
|
|
|
|
it('init keeps localStorage values when the user is anonymous (401)', async () => {
|
|
localStorage.setItem('mangalord-reader-mode', 'continuous');
|
|
localStorage.setItem('mangalord-reader-gap', 'medium');
|
|
fetchSpy.mockResolvedValueOnce(envelope(401, 'unauthenticated', 'no session'));
|
|
|
|
await preferences.init();
|
|
expect(preferences.readerMode).toBe('continuous');
|
|
expect(preferences.readerPageGap).toBe('medium');
|
|
});
|
|
|
|
it('setMode updates state, writes localStorage, and PATCHes the server', async () => {
|
|
fetchSpy.mockResolvedValueOnce(
|
|
ok({
|
|
reader_mode: 'continuous',
|
|
reader_page_gap: 'none',
|
|
updated_at: '2026-05-17T12:00:00Z'
|
|
})
|
|
);
|
|
preferences.setMode('continuous');
|
|
expect(preferences.readerMode).toBe('continuous');
|
|
expect(localStorage.getItem('mangalord-reader-mode')).toBe('continuous');
|
|
|
|
await flush();
|
|
expect(fetchSpy).toHaveBeenCalledOnce();
|
|
const init = fetchSpy.mock.calls[0][1] as RequestInit;
|
|
expect(init.method).toBe('PATCH');
|
|
expect(JSON.parse(init.body as string)).toEqual({ reader_mode: 'continuous' });
|
|
});
|
|
|
|
it('setGap survives a 401 (guest) without throwing', async () => {
|
|
fetchSpy.mockResolvedValueOnce(envelope(401, 'unauthenticated', 'no session'));
|
|
preferences.setGap('large');
|
|
expect(preferences.readerPageGap).toBe('large');
|
|
await flush();
|
|
// localStorage still holds the choice — the guest preference persists.
|
|
expect(localStorage.getItem('mangalord-reader-gap')).toBe('large');
|
|
});
|
|
|
|
it('setMode reverts the optimistic update when the server returns 5xx', async () => {
|
|
// Suppress the expected console.error so the test output stays clean.
|
|
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
fetchSpy.mockResolvedValueOnce(envelope(500, 'internal_error', 'boom'));
|
|
preferences.setMode('continuous');
|
|
expect(preferences.readerMode).toBe('continuous');
|
|
await flush();
|
|
expect(preferences.readerMode).toBe('single');
|
|
expect(localStorage.getItem('mangalord-reader-mode')).toBe('single');
|
|
errSpy.mockRestore();
|
|
});
|
|
|
|
it('clearForLogout resets state and removes localStorage entries', () => {
|
|
localStorage.setItem('mangalord-reader-mode', 'continuous');
|
|
localStorage.setItem('mangalord-reader-gap', 'large');
|
|
preferences.clearForLogout();
|
|
expect(preferences.readerMode).toBe('single');
|
|
expect(preferences.readerPageGap).toBe('none');
|
|
expect(localStorage.getItem('mangalord-reader-mode')).toBeNull();
|
|
expect(localStorage.getItem('mangalord-reader-gap')).toBeNull();
|
|
});
|
|
|
|
it('init ignores an unknown reader_page_gap from the server (forward compat)', async () => {
|
|
localStorage.setItem('mangalord-reader-mode', 'continuous');
|
|
localStorage.setItem('mangalord-reader-gap', 'medium');
|
|
fetchSpy.mockResolvedValueOnce(
|
|
ok({
|
|
reader_mode: 'continuous',
|
|
reader_page_gap: 'huge',
|
|
updated_at: '2026-05-17T12:00:00Z'
|
|
})
|
|
);
|
|
await preferences.init();
|
|
// Mode comes through (known value); unknown gap is rejected, the
|
|
// pre-existing 'medium' value is retained.
|
|
expect(preferences.readerMode).toBe('continuous');
|
|
expect(preferences.readerPageGap).toBe('medium');
|
|
});
|
|
});
|